From e0a18e0b15667ae56bfe6f999b48331848ef7062 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 10 Feb 2026 19:18:55 +0500 Subject: [PATCH 01/34] fix conversation history --- .../src/comps/comps/chatComp/chatComp.tsx | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index 57ac9040a..ca10208c3 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -4,6 +4,7 @@ import { UICompBuilder } from "comps/generators"; import { NameConfig, withExposingConfigs } from "comps/generators/withExposing"; import { StringControl } from "comps/controls/codeControl"; import { arrayObjectExposingStateControl, stringExposingStateControl } from "comps/controls/codeStateControl"; +import { JSONObject } from "util/jsonTypes"; import { withDefault } from "comps/generators"; import { BoolControl } from "comps/controls/boolControl"; import { dropdownControl } from "comps/controls/dropdownControl"; @@ -155,7 +156,9 @@ export const chatChildrenMap = { // Exposed Variables (not shown in Property View) currentMessage: stringExposingStateControl("currentMessage", ""), - conversationHistory: stringExposingStateControl("conversationHistory", "[]"), + // Use arrayObjectExposingStateControl for proper Lowcoder pattern + // This exposes: conversationHistory.value, setConversationHistory(), clearConversationHistory(), resetConversationHistory() + conversationHistory: arrayObjectExposingStateControl("conversationHistory", [] as JSONObject[]), }; // ============================================================================ @@ -221,30 +224,32 @@ const ChatTmpComp = new UICompBuilder( ]); // Handle message updates for exposed variable + // Using Lowcoder pattern: props.currentMessage.onChange() instead of dispatch(changeChildAction(...)) const handleMessageUpdate = (message: string) => { - dispatch(changeChildAction("currentMessage", message, false)); + props.currentMessage.onChange(message); // Trigger messageSent event props.onEvent("messageSent"); }; // Handle conversation history updates for exposed variable - // Handle conversation history updates for exposed variable -const handleConversationUpdate = (conversationHistory: any[]) => { - // Use utility function to create complete history with system prompt - const historyWithSystemPrompt = addSystemPromptToHistory( - conversationHistory, - props.systemPrompt - ); - - // Expose the complete history (with system prompt) for use in queries - dispatch(changeChildAction("conversationHistory", JSON.stringify(historyWithSystemPrompt), false)); - - // Trigger messageReceived event when bot responds - const lastMessage = conversationHistory[conversationHistory.length - 1]; - if (lastMessage && lastMessage.role === 'assistant') { - props.onEvent("messageReceived"); - } -}; + // Using Lowcoder pattern: props.conversationHistory.onChange() instead of dispatch(changeChildAction(...)) + const handleConversationUpdate = (messages: ChatMessage[]) => { + // Use utility function to create complete history with system prompt + const historyWithSystemPrompt = addSystemPromptToHistory( + messages, + props.systemPrompt + ); + + // Update using proper Lowcoder pattern - calling onChange on the control + // This properly updates the exposed variable and triggers reactivity + props.conversationHistory.onChange(historyWithSystemPrompt as JSONObject[]); + + // Trigger messageReceived event when bot responds + const lastMessage = messages[messages.length - 1]; + if (lastMessage && lastMessage.role === 'assistant') { + props.onEvent("messageReceived"); + } + }; // Cleanup on unmount useEffect(() => { @@ -277,6 +282,7 @@ const handleConversationUpdate = (conversationHistory: any[]) => { export const ChatComp = withExposingConfigs(ChatTmpComp, [ new NameConfig("currentMessage", "Current user message"), - new NameConfig("conversationHistory", "Full conversation history as JSON array (includes system prompt for API calls)"), + // conversationHistory is now a proper array (not JSON string) - supports setConversationHistory(), clearConversationHistory(), resetConversationHistory() + new NameConfig("conversationHistory", "Full conversation history array with system prompt (use directly in API calls, no JSON.parse needed)"), new NameConfig("databaseName", "Database name for SQL queries (ChatDB_)"), ]); \ No newline at end of file From 74f17878c58a5ec6fe31caf79937b7778b0337a4 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 10 Feb 2026 22:57:45 +0500 Subject: [PATCH 02/34] fix height messages window + add height mode (auto/fixed) for chat component --- .../src/comps/comps/chatComp/chatComp.tsx | 16 +++++++++++++- .../comps/comps/chatComp/chatPropertyView.tsx | 22 +++++++++++++------ .../comps/chatComp/components/ChatCore.tsx | 10 ++++++--- .../chatComp/components/ChatCoreMain.tsx | 17 ++++++++++---- .../comps/comps/chatComp/types/chatTypes.ts | 2 ++ .../packages/lowcoder/src/i18n/locales/en.ts | 4 ++++ 6 files changed, 56 insertions(+), 15 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index ca10208c3..58510de2f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -10,6 +10,7 @@ import { BoolControl } from "comps/controls/boolControl"; import { dropdownControl } from "comps/controls/dropdownControl"; import QuerySelectControl from "comps/controls/querySelectControl"; import { eventHandlerControl, EventConfigType } from "comps/controls/eventHandlerControl"; +import { AutoHeightControl } from "comps/controls/autoHeightControl"; import { ChatCore } from "./components/ChatCore"; import { ChatPropertyView } from "./chatPropertyView"; import { createChatStorage } from "./utils/storageFactory"; @@ -148,6 +149,10 @@ export const chatChildrenMap = { // UI Configuration placeholder: withDefault(StringControl, trans("chat.defaultPlaceholder")), + // Layout Configuration + autoHeight: AutoHeightControl, + leftPanelWidth: withDefault(StringControl, "250px"), + // Database Information (read-only) databaseName: withDefault(StringControl, ""), @@ -266,6 +271,8 @@ const ChatTmpComp = new UICompBuilder( storage={storage} messageHandler={messageHandler} placeholder={props.placeholder} + autoHeight={props.autoHeight} + sidebarWidth={props.leftPanelWidth} onMessageUpdate={handleMessageUpdate} onConversationUpdate={handleConversationUpdate} onEvent={props.onEvent} @@ -276,11 +283,18 @@ const ChatTmpComp = new UICompBuilder( .setPropertyViewFn((children) => ) .build(); +// Override autoHeight to support AUTO/FIXED height mode +const ChatCompWithAutoHeight = class extends ChatTmpComp { + override autoHeight(): boolean { + return this.children.autoHeight.getView(); + } +}; + // ============================================================================ // EXPORT WITH EXPOSED VARIABLES // ============================================================================ -export const ChatComp = withExposingConfigs(ChatTmpComp, [ +export const ChatComp = withExposingConfigs(ChatCompWithAutoHeight, [ new NameConfig("currentMessage", "Current user message"), // conversationHistory is now a proper array (not JSON string) - supports setConversationHistory(), clearConversationHistory(), resetConversationHistory() new NameConfig("conversationHistory", "Full conversation history array with system prompt (use directly in API calls, no JSON.parse needed)"), diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx index 0e2fd0290..d1589500f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx @@ -2,7 +2,6 @@ import React, { useMemo } from "react"; import { Section, sectionNames, DocLink } from "lowcoder-design"; -import { placeholderPropertyView } from "../../utils/propertyUtils"; import { trans } from "i18n"; // ============================================================================ @@ -55,7 +54,7 @@ export const ChatPropertyView = React.memo((props: any) => { tooltip: trans("chat.systemPromptTooltip"), })} - {children.streaming.propertyView({ + {children.streaming.propertyView({ label: trans("chat.streaming"), tooltip: trans("chat.streamingTooltip"), })} @@ -63,11 +62,20 @@ export const ChatPropertyView = React.memo((props: any) => { {/* UI Configuration */}
- {children.placeholder.propertyView({ - label: trans("chat.placeholderLabel"), - placeholder: trans("chat.defaultPlaceholder"), - tooltip: trans("chat.placeholderTooltip"), - })} + {children.placeholder.propertyView({ + label: trans("chat.placeholderLabel"), + placeholder: trans("chat.defaultPlaceholder"), + tooltip: trans("chat.placeholderTooltip"), + })} +
+ + {/* Layout Section - Height Mode & Sidebar Width */} +
+ {children.autoHeight.getPropertyView()} + {children.leftPanelWidth.propertyView({ + label: trans("chat.leftPanelWidth"), + tooltip: trans("chat.leftPanelWidthTooltip"), + })}
{/* Database Section */} diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx index ad0d33e2c..5059dc3db 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx @@ -14,6 +14,8 @@ export function ChatCore({ storage, messageHandler, placeholder, + autoHeight, + sidebarWidth, onMessageUpdate, onConversationUpdate, onEvent @@ -23,9 +25,11 @@ export function ChatCore({ diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx index d5b0ce187..c4bd77dbc 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx @@ -27,16 +27,20 @@ import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; // STYLED COMPONENTS (same as your current ChatMain) // ============================================================================ -const ChatContainer = styled.div` +const ChatContainer = styled.div<{ + $autoHeight?: boolean; + $sidebarWidth?: string; +}>` display: flex; - height: 500px; + height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; + min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; p { margin: 0; } .aui-thread-list-root { - width: 250px; + width: ${(props) => props.$sidebarWidth || "250px"}; background-color: #fff; padding: 10px; } @@ -44,6 +48,7 @@ const ChatContainer = styled.div` .aui-thread-root { flex: 1; background-color: #f9fafb; + height: auto; } .aui-thread-list-item { @@ -64,6 +69,8 @@ const ChatContainer = styled.div` interface ChatCoreMainProps { messageHandler: MessageHandler; placeholder?: string; + autoHeight?: boolean; + sidebarWidth?: string; onMessageUpdate?: (message: string) => void; onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK (OPTIONAL) @@ -75,6 +82,8 @@ const generateId = () => Math.random().toString(36).substr(2, 9); export function ChatCoreMain({ messageHandler, placeholder, + autoHeight, + sidebarWidth, onMessageUpdate, onConversationUpdate, onEvent @@ -305,7 +314,7 @@ export function ChatCoreMain({ return ( - + diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index 5e757f231..23bf16df5 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -75,6 +75,8 @@ export interface ChatMessage { storage: ChatStorage; messageHandler: MessageHandler; placeholder?: string; + autoHeight?: boolean; + sidebarWidth?: string; onMessageUpdate?: (message: string) => void; onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 56fa433e2..ce330e7b9 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -1477,6 +1477,10 @@ export const en = { "threadDeleted": "Thread Deleted", "threadDeletedDesc": "Triggered when a thread is deleted - Delete thread from backend", + // Layout + "leftPanelWidth": "Sidebar Width", + "leftPanelWidthTooltip": "Width of the thread list sidebar (e.g., 250px, 30%)", + // Exposed Variables (for documentation) "currentMessage": "Current user message", "conversationHistory": "Full conversation history as JSON array", From b4620e5a7a31238b2167914e0584b93a85c65f3f Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Wed, 11 Feb 2026 22:46:12 +0500 Subject: [PATCH 03/34] add style customization --- .../src/comps/comps/chatComp/chatComp.tsx | 26 ++++ .../comps/comps/chatComp/chatPropertyView.tsx | 30 +++++ .../comps/chatComp/components/ChatCore.tsx | 16 ++- .../chatComp/components/ChatCoreMain.tsx | 95 +++++++++++++- .../comps/comps/chatComp/types/chatTypes.ts | 30 +++-- .../comps/controls/styleControlConstants.tsx | 124 ++++++++++++++++++ .../packages/lowcoder/src/i18n/locales/en.ts | 25 +++- 7 files changed, 329 insertions(+), 17 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index 58510de2f..e011582c3 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -19,6 +19,16 @@ import { useMemo, useRef, useEffect } from "react"; import { changeChildAction } from "lowcoder-core"; import { ChatMessage } from "./types/chatTypes"; import { trans } from "i18n"; +import { styleControl } from "comps/controls/styleControl"; +import { + ChatStyle, + ChatSidebarStyle, + ChatMessagesStyle, + ChatInputStyle, + ChatSendButtonStyle, + ChatNewThreadButtonStyle, +} from "comps/controls/styleControlConstants"; +import { AnimationStyle } from "comps/controls/styleControlConstants"; import "@assistant-ui/styles/index.css"; import "@assistant-ui/styles/markdown.css"; @@ -159,6 +169,15 @@ export const chatChildrenMap = { // Event Handlers onEvent: ChatEventHandlerControl, + // Style Controls + style: styleControl(ChatStyle), + sidebarStyle: styleControl(ChatSidebarStyle), + messagesStyle: styleControl(ChatMessagesStyle), + inputStyle: styleControl(ChatInputStyle), + sendButtonStyle: styleControl(ChatSendButtonStyle), + newThreadButtonStyle: styleControl(ChatNewThreadButtonStyle), + animationStyle: styleControl(AnimationStyle), + // Exposed Variables (not shown in Property View) currentMessage: stringExposingStateControl("currentMessage", ""), // Use arrayObjectExposingStateControl for proper Lowcoder pattern @@ -276,6 +295,13 @@ const ChatTmpComp = new UICompBuilder( onMessageUpdate={handleMessageUpdate} onConversationUpdate={handleConversationUpdate} onEvent={props.onEvent} + style={props.style} + sidebarStyle={props.sidebarStyle} + messagesStyle={props.messagesStyle} + inputStyle={props.inputStyle} + sendButtonStyle={props.sendButtonStyle} + newThreadButtonStyle={props.newThreadButtonStyle} + animationStyle={props.animationStyle} /> ); } diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx index d1589500f..1cda06b3d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx @@ -3,6 +3,7 @@ import React, { useMemo } from "react"; import { Section, sectionNames, DocLink } from "lowcoder-design"; import { trans } from "i18n"; +import { hiddenPropertyView } from "comps/utils/propertyUtils"; // ============================================================================ // CLEAN PROPERTY VIEW - FOCUSED ON ESSENTIAL CONFIGURATION @@ -92,6 +93,35 @@ export const ChatPropertyView = React.memo((props: any) => { {children.onEvent.getPropertyView()} + {/* STYLE SECTIONS */} +
+ {children.style.getPropertyView()} +
+ +
+ {children.sidebarStyle.getPropertyView()} +
+ +
+ {children.messagesStyle.getPropertyView()} +
+ +
+ {children.inputStyle.getPropertyView()} +
+ +
+ {children.sendButtonStyle.getPropertyView()} +
+ +
+ {children.newThreadButtonStyle.getPropertyView()} +
+ +
+ {children.animationStyle.getPropertyView()} +
+ ), [children]); }); diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx index 5059dc3db..ff8e7b009 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx @@ -18,7 +18,14 @@ export function ChatCore({ sidebarWidth, onMessageUpdate, onConversationUpdate, - onEvent + onEvent, + style, + sidebarStyle, + messagesStyle, + inputStyle, + sendButtonStyle, + newThreadButtonStyle, + animationStyle }: ChatCoreProps) { return ( @@ -31,6 +38,13 @@ export function ChatCore({ onMessageUpdate={onMessageUpdate} onConversationUpdate={onConversationUpdate} onEvent={onEvent} + style={style} + sidebarStyle={sidebarStyle} + messagesStyle={messagesStyle} + inputStyle={inputStyle} + sendButtonStyle={sendButtonStyle} + newThreadButtonStyle={newThreadButtonStyle} + animationStyle={animationStyle} /> diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx index c4bd77dbc..ab6ff50a5 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx @@ -30,27 +30,89 @@ import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; const ChatContainer = styled.div<{ $autoHeight?: boolean; $sidebarWidth?: string; + $style?: any; + $sidebarStyle?: any; + $messagesStyle?: any; + $inputStyle?: any; + $sendButtonStyle?: any; + $newThreadButtonStyle?: any; + $animationStyle?: any; }>` display: flex; height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; + /* Main container styles */ + background: ${(props) => props.$style?.background || "transparent"}; + margin: ${(props) => props.$style?.margin || "0"}; + padding: ${(props) => props.$style?.padding || "0"}; + border: ${(props) => props.$style?.borderWidth || "0"} ${(props) => props.$style?.borderStyle || "solid"} ${(props) => props.$style?.border || "transparent"}; + border-radius: ${(props) => props.$style?.radius || "0"}; + + /* Animation styles */ + animation: ${(props) => props.$animationStyle?.animation || "none"}; + animation-duration: ${(props) => props.$animationStyle?.animationDuration || "0s"}; + animation-delay: ${(props) => props.$animationStyle?.animationDelay || "0s"}; + animation-iteration-count: ${(props) => props.$animationStyle?.animationIterationCount || "1"}; + p { margin: 0; } + /* Sidebar Styles */ .aui-thread-list-root { width: ${(props) => props.$sidebarWidth || "250px"}; - background-color: #fff; + background-color: ${(props) => props.$sidebarStyle?.sidebarBackground || "#fff"}; padding: 10px; } + .aui-thread-list-item-title { + color: ${(props) => props.$sidebarStyle?.threadText || "inherit"}; + } + + /* Messages Window Styles */ .aui-thread-root { flex: 1; - background-color: #f9fafb; + background-color: ${(props) => props.$messagesStyle?.messagesBackground || "#f9fafb"}; height: auto; } + /* User Message Styles */ + .aui-user-message-content { + background-color: ${(props) => props.$messagesStyle?.userMessageBackground || "#3b82f6"}; + color: ${(props) => props.$messagesStyle?.userMessageText || "#ffffff"}; + } + + /* Assistant Message Styles */ + .aui-assistant-message-content { + background-color: ${(props) => props.$messagesStyle?.assistantMessageBackground || "#ffffff"}; + color: ${(props) => props.$messagesStyle?.assistantMessageText || "inherit"}; + } + + /* Input Field Styles */ + .aui-composer-input { + background-color: ${(props) => props.$inputStyle?.inputBackground || "#ffffff"}; + color: ${(props) => props.$inputStyle?.inputText || "inherit"}; + border-color: ${(props) => props.$inputStyle?.inputBorder || "#d1d5db"}; + } + + /* Send Button Styles */ + .aui-composer-send { + background-color: ${(props) => props.$sendButtonStyle?.sendButtonBackground || "#3b82f6"} !important; + + svg { + color: ${(props) => props.$sendButtonStyle?.sendButtonIcon || "#ffffff"}; + } + } + + /* New Thread Button Styles */ + .aui-thread-list-root button[type="button"]:first-child { + background-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + color: ${(props) => props.$newThreadButtonStyle?.newThreadText || "#ffffff"} !important; + border-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + } + + /* Thread item styling */ .aui-thread-list-item { cursor: pointer; transition: background-color 0.2s ease; @@ -75,6 +137,14 @@ interface ChatCoreMainProps { onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK (OPTIONAL) onEvent?: (eventName: string) => void; + // Style controls + style?: any; + sidebarStyle?: any; + messagesStyle?: any; + inputStyle?: any; + sendButtonStyle?: any; + newThreadButtonStyle?: any; + animationStyle?: any; } const generateId = () => Math.random().toString(36).substr(2, 9); @@ -86,7 +156,14 @@ export function ChatCoreMain({ sidebarWidth, onMessageUpdate, onConversationUpdate, - onEvent + onEvent, + style, + sidebarStyle, + messagesStyle, + inputStyle, + sendButtonStyle, + newThreadButtonStyle, + animationStyle }: ChatCoreMainProps) { const { state, actions } = useChatContext(); const [isRunning, setIsRunning] = useState(false); @@ -314,7 +391,17 @@ export function ChatCoreMain({ return ( - + diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index 23bf16df5..472d13c59 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -71,17 +71,25 @@ export interface ChatMessage { // COMPONENT PROPS (what each component actually needs) // ============================================================================ - export interface ChatCoreProps { - storage: ChatStorage; - messageHandler: MessageHandler; - placeholder?: string; - autoHeight?: boolean; - sidebarWidth?: string; - onMessageUpdate?: (message: string) => void; - onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; - // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK - onEvent?: (eventName: string) => void; - } +export interface ChatCoreProps { + storage: ChatStorage; + messageHandler: MessageHandler; + placeholder?: string; + autoHeight?: boolean; + sidebarWidth?: string; + onMessageUpdate?: (message: string) => void; + onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; + // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK + onEvent?: (eventName: string) => void; + // Style controls + style?: any; + sidebarStyle?: any; + messagesStyle?: any; + inputStyle?: any; + sendButtonStyle?: any; + newThreadButtonStyle?: any; + animationStyle?: any; +} export interface ChatPanelProps { tableName: string; diff --git a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx index 176afbbfc..09aa476a0 100644 --- a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx +++ b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx @@ -2372,6 +2372,123 @@ export const RichTextEditorStyle = [ BORDER_WIDTH, ] as const; +// Chat Component Styles +export const ChatStyle = [ + getBackground(), + MARGIN, + PADDING, + BORDER, + BORDER_STYLE, + RADIUS, + BORDER_WIDTH, +] as const; + +export const ChatSidebarStyle = [ + { + name: "sidebarBackground", + label: trans("style.sidebarBackground"), + depTheme: "primarySurface", + depType: DEP_TYPE.SELF, + transformer: toSelf, + }, + { + name: "threadText", + label: trans("style.threadText"), + depName: "sidebarBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, +] as const; + +export const ChatMessagesStyle = [ + { + name: "messagesBackground", + label: trans("style.messagesBackground"), + color: "#f9fafb", + }, + { + name: "userMessageBackground", + label: trans("style.userMessageBackground"), + depTheme: "primary", + depType: DEP_TYPE.SELF, + transformer: toSelf, + }, + { + name: "userMessageText", + label: trans("style.userMessageText"), + depName: "userMessageBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, + { + name: "assistantMessageBackground", + label: trans("style.assistantMessageBackground"), + color: "#ffffff", + }, + { + name: "assistantMessageText", + label: trans("style.assistantMessageText"), + depName: "assistantMessageBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, +] as const; + +export const ChatInputStyle = [ + { + name: "inputBackground", + label: trans("style.inputBackground"), + color: "#ffffff", + }, + { + name: "inputText", + label: trans("style.inputText"), + depName: "inputBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, + { + name: "inputBorder", + label: trans("style.inputBorder"), + depName: "inputBackground", + transformer: backgroundToBorder, + }, +] as const; + +export const ChatSendButtonStyle = [ + { + name: "sendButtonBackground", + label: trans("style.sendButtonBackground"), + depTheme: "primary", + depType: DEP_TYPE.SELF, + transformer: toSelf, + }, + { + name: "sendButtonIcon", + label: trans("style.sendButtonIcon"), + depName: "sendButtonBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, +] as const; + +export const ChatNewThreadButtonStyle = [ + { + name: "newThreadBackground", + label: trans("style.newThreadBackground"), + depTheme: "primary", + depType: DEP_TYPE.SELF, + transformer: toSelf, + }, + { + name: "newThreadText", + label: trans("style.newThreadText"), + depName: "newThreadBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, +] as const; + export type QRCodeStyleType = StyleConfigType; export type TimeLineStyleType = StyleConfigType; export type AvatarStyleType = StyleConfigType; @@ -2490,6 +2607,13 @@ export type NavLayoutItemActiveStyleType = StyleConfigType< typeof NavLayoutItemActiveStyle >; +export type ChatStyleType = StyleConfigType; +export type ChatSidebarStyleType = StyleConfigType; +export type ChatMessagesStyleType = StyleConfigType; +export type ChatInputStyleType = StyleConfigType; +export type ChatSendButtonStyleType = StyleConfigType; +export type ChatNewThreadButtonStyleType = StyleConfigType; + export function widthCalculator(margin: string) { const marginArr = margin?.trim().replace(/\s+/g, " ").split(" ") || ""; if (marginArr.length === 1) { diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index ce330e7b9..9a1ecbae2 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -600,6 +600,22 @@ export const en = { "detailSize": "Detail Size", "hideColumn": "Hide Column", + // Chat Component Styles + "sidebarBackground": "Sidebar Background", + "threadText": "Thread Text Color", + "messagesBackground": "Messages Background", + "userMessageBackground": "User Message Background", + "userMessageText": "User Message Text", + "assistantMessageBackground": "Assistant Message Background", + "assistantMessageText": "Assistant Message Text", + "inputBackground": "Input Background", + "inputText": "Input Text Color", + "inputBorder": "Input Border", + "sendButtonBackground": "Send Button Background", + "sendButtonIcon": "Send Button Icon Color", + "newThreadBackground": "New Thread Button Background", + "newThreadText": "New Thread Button Text", + "radiusTip": "Specifies the radius of the element's corners. Example: 5px, 50%, or 1em.", "gapTip": "Specifies the gap between rows and columns in a grid or flex container. Example: 10px, 1rem, or 5%.", "cardRadiusTip": "Defines the corner radius for card components. Example: 10px, 15px.", @@ -1484,7 +1500,14 @@ export const en = { // Exposed Variables (for documentation) "currentMessage": "Current user message", "conversationHistory": "Full conversation history as JSON array", - "databaseNameExposed": "Database name for SQL queries (ChatDB_)" + "databaseNameExposed": "Database name for SQL queries (ChatDB_)", + + // Style Section Names + "sidebarStyle": "Sidebar Style", + "messagesStyle": "Messages Style", + "inputStyle": "Input Field Style", + "sendButtonStyle": "Send Button Style", + "newThreadButtonStyle": "New Thread Button Style" }, "chatBox": { From 5875702f611c2d444747cff3a6b9ad1da00f9cb7 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 12 Feb 2026 19:39:18 +0500 Subject: [PATCH 04/34] seperate chat panel component --- .../chatComp/components/ChatCoreMain.tsx | 3 +- .../comps/chatComp/components/ChatPanel.tsx | 19 +- .../chatComp/components/ChatPanelCore.tsx | 308 ++++++++++++++++++ .../comps/comps/chatComp/types/chatTypes.ts | 28 +- 4 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelCore.tsx diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx index ab6ff50a5..db510780a 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx @@ -125,7 +125,8 @@ const ChatContainer = styled.div<{ `; // ============================================================================ -// CHAT CORE MAIN - CLEAN PROPS, FOCUSED RESPONSIBILITY +// CHAT CORE MAIN - FOR MAIN COMPONENT WITH FULL STYLING SUPPORT +// (Bottom panel uses ChatPanelCore instead - see ChatPanelCore.tsx) // ============================================================================ interface ChatCoreMainProps { diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx index 1c9af4f55..a0586b67a 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx @@ -1,17 +1,19 @@ // client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx import { useMemo } from "react"; -import { ChatCore } from "./ChatCore"; +import { ChatProvider } from "./context/ChatContext"; +import { ChatPanelCore } from "./ChatPanelCore"; import { createChatStorage } from "../utils/storageFactory"; import { N8NHandler } from "../handlers/messageHandlers"; import { ChatPanelProps } from "../types/chatTypes"; import { trans } from "i18n"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; import "@assistant-ui/styles/index.css"; import "@assistant-ui/styles/markdown.css"; // ============================================================================ -// CHAT PANEL - CLEAN BOTTOM PANEL COMPONENT +// CHAT PANEL - SIMPLIFIED BOTTOM PANEL COMPONENT (NO STYLING CONTROLS) // ============================================================================ export function ChatPanel({ @@ -38,10 +40,13 @@ export function ChatPanel({ ); return ( - + + + + + ); } \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelCore.tsx new file mode 100644 index 000000000..f0978e56b --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelCore.tsx @@ -0,0 +1,308 @@ +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelCore.tsx + +import React, { useState, useEffect } from "react"; +import { + useExternalStoreRuntime, + ThreadMessageLike, + AppendMessage, + AssistantRuntimeProvider, + ExternalStoreThreadListAdapter, + CompleteAttachment, + TextContentPart, + ThreadUserContentPart +} from "@assistant-ui/react"; +import { Thread } from "./assistant-ui/thread"; +import { ThreadList } from "./assistant-ui/thread-list"; +import { + useChatContext, + RegularThreadData, + ArchivedThreadData +} from "./context/ChatContext"; +import { MessageHandler, ChatMessage } from "../types/chatTypes"; +import styled from "styled-components"; +import { trans } from "i18n"; +import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; + +// ============================================================================ +// SIMPLE STYLED COMPONENTS - FIXED STYLING FOR BOTTOM PANEL +// ============================================================================ + +const ChatContainer = styled.div<{ + $autoHeight?: boolean; + $sidebarWidth?: string; +}>` + display: flex; + height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; + min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; + + p { + margin: 0; + } + + .aui-thread-list-root { + width: ${(props) => props.$sidebarWidth || "250px"}; + background-color: #fff; + padding: 10px; + } + + .aui-thread-root { + flex: 1; + background-color: #f9fafb; + height: auto; + } + + .aui-thread-list-item { + cursor: pointer; + transition: background-color 0.2s ease; + + &[data-active="true"] { + background-color: #dbeafe; + border: 1px solid #bfdbfe; + } + } +`; + +// ============================================================================ +// CHAT PANEL CORE - SIMPLIFIED FOR BOTTOM PANEL (NO STYLING PROPS) +// ============================================================================ + +interface ChatPanelCoreProps { + messageHandler: MessageHandler; + placeholder?: string; + autoHeight?: boolean; + sidebarWidth?: string; + onMessageUpdate?: (message: string) => void; + onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; + onEvent?: (eventName: string) => void; +} + +const generateId = () => Math.random().toString(36).substr(2, 9); + +export function ChatPanelCore({ + messageHandler, + placeholder, + autoHeight, + sidebarWidth, + onMessageUpdate, + onConversationUpdate, + onEvent +}: ChatPanelCoreProps) { + const { state, actions } = useChatContext(); + const [isRunning, setIsRunning] = useState(false); + + // Get messages for current thread + const currentMessages = actions.getCurrentMessages(); + + // Notify parent component of conversation changes + useEffect(() => { + if (currentMessages.length > 0 && !isRunning) { + onConversationUpdate?.(currentMessages); + } + }, [currentMessages, isRunning]); + + // Trigger component load event on mount + useEffect(() => { + onEvent?.("componentLoad"); + }, [onEvent]); + + // Convert custom format to ThreadMessageLike + const convertMessage = (message: ChatMessage): ThreadMessageLike => { + const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; + + if (message.attachments && message.attachments.length > 0) { + for (const attachment of message.attachments) { + if (attachment.content) { + content.push(...attachment.content); + } + } + } + + return { + role: message.role, + content, + id: message.id, + createdAt: new Date(message.timestamp), + ...(message.attachments && message.attachments.length > 0 && { attachments: message.attachments }), + }; + }; + + // Handle new message + const onNew = async (message: AppendMessage) => { + const textPart = (message.content as ThreadUserContentPart[]).find( + (part): part is TextContentPart => part.type === "text" + ); + + const text = textPart?.text?.trim() ?? ""; + + const completeAttachments = (message.attachments ?? []).filter( + (att): att is CompleteAttachment => att.status.type === "complete" + ); + + const hasText = text.length > 0; + const hasAttachments = completeAttachments.length > 0; + + if (!hasText && !hasAttachments) { + throw new Error("Cannot send an empty message"); + } + + const userMessage: ChatMessage = { + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + attachments: completeAttachments, + }; + + await actions.addMessage(state.currentThreadId, userMessage); + setIsRunning(true); + + try { + const response = await messageHandler.sendMessage(userMessage); + + onMessageUpdate?.(userMessage.text); + + const assistantMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }; + + await actions.addMessage(state.currentThreadId, assistantMessage); + } catch (error) { + const errorMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }; + + await actions.addMessage(state.currentThreadId, errorMessage); + } finally { + setIsRunning(false); + } + }; + + // Handle edit message + const onEdit = async (message: AppendMessage) => { + const textPart = (message.content as ThreadUserContentPart[]).find( + (part): part is TextContentPart => part.type === "text" + ); + + const text = textPart?.text?.trim() ?? ""; + + const completeAttachments = (message.attachments ?? []).filter( + (att): att is CompleteAttachment => att.status.type === "complete" + ); + + const hasText = text.length > 0; + const hasAttachments = completeAttachments.length > 0; + + if (!hasText && !hasAttachments) { + throw new Error("Cannot send an empty message"); + } + + const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; + const newMessages = [...currentMessages.slice(0, index)]; + + const editedMessage: ChatMessage = { + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + attachments: completeAttachments, + }; + + newMessages.push(editedMessage); + await actions.updateMessages(state.currentThreadId, newMessages); + setIsRunning(true); + + try { + const response = await messageHandler.sendMessage(editedMessage); + + onMessageUpdate?.(editedMessage.text); + + const assistantMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }; + + newMessages.push(assistantMessage); + await actions.updateMessages(state.currentThreadId, newMessages); + } catch (error) { + const errorMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }; + + newMessages.push(errorMessage); + await actions.updateMessages(state.currentThreadId, newMessages); + } finally { + setIsRunning(false); + } + }; + + // Thread list adapter + const threadListAdapter: ExternalStoreThreadListAdapter = { + threadId: state.currentThreadId, + threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"), + archivedThreads: state.threadList.filter((t): t is ArchivedThreadData => t.status === "archived"), + + onSwitchToNewThread: async () => { + const threadId = await actions.createThread(trans("chat.newChatTitle")); + actions.setCurrentThread(threadId); + onEvent?.("threadCreated"); + }, + + onSwitchToThread: (threadId) => { + actions.setCurrentThread(threadId); + }, + + onRename: async (threadId, newTitle) => { + await actions.updateThread(threadId, { title: newTitle }); + onEvent?.("threadUpdated"); + }, + + onArchive: async (threadId) => { + await actions.updateThread(threadId, { status: "archived" }); + onEvent?.("threadUpdated"); + }, + + onDelete: async (threadId) => { + await actions.deleteThread(threadId); + onEvent?.("threadDeleted"); + }, + }; + + const runtime = useExternalStoreRuntime({ + messages: currentMessages, + setMessages: (messages) => { + actions.updateMessages(state.currentThreadId, messages); + }, + convertMessage, + isRunning, + onNew, + onEdit, + adapters: { + threadList: threadListAdapter, + attachments: universalAttachmentAdapter, + }, + }); + + if (!state.isInitialized) { + return
Loading...
; + } + + return ( + + + + + + + ); +} diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index 472d13c59..0d2bd7f9c 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -67,10 +67,11 @@ export interface ChatMessage { systemPrompt?: string; } - // ============================================================================ - // COMPONENT PROPS (what each component actually needs) - // ============================================================================ - +// ============================================================================ +// COMPONENT PROPS (what each component actually needs) +// ============================================================================ + +// Main Chat Component Props (with full styling support) export interface ChatCoreProps { storage: ChatStorage; messageHandler: MessageHandler; @@ -81,7 +82,7 @@ export interface ChatCoreProps { onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK onEvent?: (eventName: string) => void; - // Style controls + // Style controls (only for main component) style?: any; sidebarStyle?: any; messagesStyle?: any; @@ -90,11 +91,12 @@ export interface ChatCoreProps { newThreadButtonStyle?: any; animationStyle?: any; } - - export interface ChatPanelProps { - tableName: string; - modelHost: string; - systemPrompt?: string; - streaming?: boolean; - onMessageUpdate?: (message: string) => void; - } + +// Bottom Panel Props (simplified, no styling controls) +export interface ChatPanelProps { + tableName: string; + modelHost: string; + systemPrompt?: string; + streaming?: boolean; + onMessageUpdate?: (message: string) => void; +} From 11bb84456ca89cf8c15e6f478a5acb4aa5139295 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 12 Feb 2026 23:53:32 +0500 Subject: [PATCH 05/34] complete chat styles --- .../src/comps/comps/chatComp/chatComp.tsx | 3 ++ .../comps/comps/chatComp/chatPropertyView.tsx | 4 +++ .../comps/chatComp/components/ChatCore.tsx | 2 ++ .../chatComp/components/ChatCoreMain.tsx | 14 ++++++-- .../comps/comps/chatComp/types/chatTypes.ts | 1 + .../comps/controls/styleControlConstants.tsx | 34 +++++++++++++++++++ .../packages/lowcoder/src/i18n/locales/en.ts | 9 ++++- 7 files changed, 63 insertions(+), 4 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index e011582c3..20cfcb00f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -27,6 +27,7 @@ import { ChatInputStyle, ChatSendButtonStyle, ChatNewThreadButtonStyle, + ChatThreadItemStyle, } from "comps/controls/styleControlConstants"; import { AnimationStyle } from "comps/controls/styleControlConstants"; @@ -176,6 +177,7 @@ export const chatChildrenMap = { inputStyle: styleControl(ChatInputStyle), sendButtonStyle: styleControl(ChatSendButtonStyle), newThreadButtonStyle: styleControl(ChatNewThreadButtonStyle), + threadItemStyle: styleControl(ChatThreadItemStyle), animationStyle: styleControl(AnimationStyle), // Exposed Variables (not shown in Property View) @@ -301,6 +303,7 @@ const ChatTmpComp = new UICompBuilder( inputStyle={props.inputStyle} sendButtonStyle={props.sendButtonStyle} newThreadButtonStyle={props.newThreadButtonStyle} + threadItemStyle={props.threadItemStyle} animationStyle={props.animationStyle} /> ); diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx index 1cda06b3d..1e396ebcb 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx @@ -118,6 +118,10 @@ export const ChatPropertyView = React.memo((props: any) => { {children.newThreadButtonStyle.getPropertyView()} +
+ {children.threadItemStyle.getPropertyView()} +
+
{children.animationStyle.getPropertyView()}
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx index ff8e7b009..0a50ccae9 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx @@ -25,6 +25,7 @@ export function ChatCore({ inputStyle, sendButtonStyle, newThreadButtonStyle, + threadItemStyle, animationStyle }: ChatCoreProps) { return ( @@ -44,6 +45,7 @@ export function ChatCore({ inputStyle={inputStyle} sendButtonStyle={sendButtonStyle} newThreadButtonStyle={newThreadButtonStyle} + threadItemStyle={threadItemStyle} animationStyle={animationStyle} /> diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx index db510780a..ec0f1b534 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx @@ -36,6 +36,7 @@ const ChatContainer = styled.div<{ $inputStyle?: any; $sendButtonStyle?: any; $newThreadButtonStyle?: any; + $threadItemStyle?: any; $animationStyle?: any; }>` display: flex; @@ -106,7 +107,7 @@ const ChatContainer = styled.div<{ } /* New Thread Button Styles */ - .aui-thread-list-root button[type="button"]:first-child { + .aui-thread-list-root > button { background-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; color: ${(props) => props.$newThreadButtonStyle?.newThreadText || "#ffffff"} !important; border-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; @@ -116,10 +117,14 @@ const ChatContainer = styled.div<{ .aui-thread-list-item { cursor: pointer; transition: background-color 0.2s ease; + background-color: ${(props) => props.$threadItemStyle?.threadItemBackground || "transparent"}; + color: ${(props) => props.$threadItemStyle?.threadItemText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.threadItemBorder || "transparent"}; &[data-active="true"] { - background-color: #dbeafe; - border: 1px solid #bfdbfe; + background-color: ${(props) => props.$threadItemStyle?.activeThreadBackground || "#dbeafe"}; + color: ${(props) => props.$threadItemStyle?.activeThreadText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.activeThreadBorder || "#bfdbfe"}; } } `; @@ -145,6 +150,7 @@ interface ChatCoreMainProps { inputStyle?: any; sendButtonStyle?: any; newThreadButtonStyle?: any; + threadItemStyle?: any; animationStyle?: any; } @@ -164,6 +170,7 @@ export function ChatCoreMain({ inputStyle, sendButtonStyle, newThreadButtonStyle, + threadItemStyle, animationStyle }: ChatCoreMainProps) { const { state, actions } = useChatContext(); @@ -401,6 +408,7 @@ export function ChatCoreMain({ $inputStyle={inputStyle} $sendButtonStyle={sendButtonStyle} $newThreadButtonStyle={newThreadButtonStyle} + $threadItemStyle={threadItemStyle} $animationStyle={animationStyle} > diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index 0d2bd7f9c..919094ba7 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -89,6 +89,7 @@ export interface ChatCoreProps { inputStyle?: any; sendButtonStyle?: any; newThreadButtonStyle?: any; + threadItemStyle?: any; animationStyle?: any; } diff --git a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx index 09aa476a0..e09e2b1fc 100644 --- a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx +++ b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx @@ -2489,6 +2489,39 @@ export const ChatNewThreadButtonStyle = [ }, ] as const; +export const ChatThreadItemStyle = [ + { + name: "threadItemBackground", + label: trans("style.threadItemBackground"), + color: "transparent", + }, + { + name: "threadItemText", + label: trans("style.threadItemText"), + color: "inherit", + }, + { + name: "threadItemBorder", + label: trans("style.threadItemBorder"), + color: "transparent", + }, + { + name: "activeThreadBackground", + label: trans("style.activeThreadBackground"), + color: "#dbeafe", + }, + { + name: "activeThreadText", + label: trans("style.activeThreadText"), + color: "inherit", + }, + { + name: "activeThreadBorder", + label: trans("style.activeThreadBorder"), + color: "#bfdbfe", + }, +] as const; + export type QRCodeStyleType = StyleConfigType; export type TimeLineStyleType = StyleConfigType; export type AvatarStyleType = StyleConfigType; @@ -2613,6 +2646,7 @@ export type ChatMessagesStyleType = StyleConfigType; export type ChatInputStyleType = StyleConfigType; export type ChatSendButtonStyleType = StyleConfigType; export type ChatNewThreadButtonStyleType = StyleConfigType; +export type ChatThreadItemStyleType = StyleConfigType; export function widthCalculator(margin: string) { const marginArr = margin?.trim().replace(/\s+/g, " ").split(" ") || ""; diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 9a1ecbae2..9c3accda6 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -615,6 +615,12 @@ export const en = { "sendButtonIcon": "Send Button Icon Color", "newThreadBackground": "New Thread Button Background", "newThreadText": "New Thread Button Text", + "threadItemBackground": "Thread Item Background", + "threadItemText": "Thread Item Text", + "threadItemBorder": "Thread Item Border", + "activeThreadBackground": "Active Thread Background", + "activeThreadText": "Active Thread Text", + "activeThreadBorder": "Active Thread Border", "radiusTip": "Specifies the radius of the element's corners. Example: 5px, 50%, or 1em.", "gapTip": "Specifies the gap between rows and columns in a grid or flex container. Example: 10px, 1rem, or 5%.", @@ -1507,7 +1513,8 @@ export const en = { "messagesStyle": "Messages Style", "inputStyle": "Input Field Style", "sendButtonStyle": "Send Button Style", - "newThreadButtonStyle": "New Thread Button Style" + "newThreadButtonStyle": "New Thread Button Style", + "threadItemStyle": "Thread Item Style" }, "chatBox": { From a12a9b9eb7f3b0efc758ca3ccc866cf30bc86577 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Sat, 14 Feb 2026 01:43:08 +0500 Subject: [PATCH 06/34] refactor ai chat & chat panel component --- .../src/comps/comps/chatComp/chatComp.tsx | 39 +- .../chatComp/components/ChatContainer.tsx | 340 ++++++++++++++ .../comps/chatComp/components/ChatCore.tsx | 54 --- .../chatComp/components/ChatCoreMain.tsx | 420 ------------------ .../comps/chatComp/components/ChatPanel.tsx | 21 +- ...atPanelCore.tsx => ChatPanelContainer.tsx} | 138 +++--- 6 files changed, 424 insertions(+), 588 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx delete mode 100644 client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx delete mode 100644 client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx rename client/packages/lowcoder/src/comps/comps/chatComp/components/{ChatPanelCore.tsx => ChatPanelContainer.tsx} (71%) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index 20cfcb00f..dc1cfa1fc 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -11,7 +11,7 @@ import { dropdownControl } from "comps/controls/dropdownControl"; import QuerySelectControl from "comps/controls/querySelectControl"; import { eventHandlerControl, EventConfigType } from "comps/controls/eventHandlerControl"; import { AutoHeightControl } from "comps/controls/autoHeightControl"; -import { ChatCore } from "./components/ChatCore"; +import { ChatContainer } from "./components/ChatContainer"; import { ChatPropertyView } from "./chatPropertyView"; import { createChatStorage } from "./utils/storageFactory"; import { QueryHandler, createMessageHandler } from "./handlers/messageHandlers"; @@ -170,15 +170,17 @@ export const chatChildrenMap = { // Event Handlers onEvent: ChatEventHandlerControl, - // Style Controls - style: styleControl(ChatStyle), - sidebarStyle: styleControl(ChatSidebarStyle), - messagesStyle: styleControl(ChatMessagesStyle), - inputStyle: styleControl(ChatInputStyle), + // Style Controls - Consolidated to reduce prop count + style: styleControl(ChatStyle), // Main container + sidebarStyle: styleControl(ChatSidebarStyle), // Sidebar (includes threads & new button) + messagesStyle: styleControl(ChatMessagesStyle), // Messages area + inputStyle: styleControl(ChatInputStyle), // Input + send button area + animationStyle: styleControl(AnimationStyle), // Animations + + // Legacy style props (kept for backward compatibility, consolidated internally) sendButtonStyle: styleControl(ChatSendButtonStyle), newThreadButtonStyle: styleControl(ChatNewThreadButtonStyle), threadItemStyle: styleControl(ChatThreadItemStyle), - animationStyle: styleControl(AnimationStyle), // Exposed Variables (not shown in Property View) currentMessage: stringExposingStateControl("currentMessage", ""), @@ -287,8 +289,20 @@ const ChatTmpComp = new UICompBuilder( }; }, []); + // Group all styles into single object for cleaner prop passing + const styles = { + style: props.style, + sidebarStyle: props.sidebarStyle, + messagesStyle: props.messagesStyle, + inputStyle: props.inputStyle, + sendButtonStyle: props.sendButtonStyle, + newThreadButtonStyle: props.newThreadButtonStyle, + threadItemStyle: props.threadItemStyle, + animationStyle: props.animationStyle, + }; + return ( - ); } diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx new file mode 100644 index 000000000..dc4c35b2d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx @@ -0,0 +1,340 @@ +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx + +import React, { useState, useEffect } from "react"; +import { + useExternalStoreRuntime, + ThreadMessageLike, + AppendMessage, + AssistantRuntimeProvider, + ExternalStoreThreadListAdapter, + CompleteAttachment, + TextContentPart, + ThreadUserContentPart +} from "@assistant-ui/react"; +import { Thread } from "./assistant-ui/thread"; +import { ThreadList } from "./assistant-ui/thread-list"; +import { + ChatProvider, + useChatContext, + RegularThreadData, + ArchivedThreadData +} from "./context/ChatContext"; +import { MessageHandler, ChatMessage, ChatCoreProps } from "../types/chatTypes"; +import styled from "styled-components"; +import { trans } from "i18n"; +import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; + +// ============================================================================ +// STYLED CONTAINER - FOLLOWING LOWCODER PATTERNS +// Styles passed as objects, applied directly like Button100 and InputStyle +// ============================================================================ + +const StyledChatContainer = styled.div` + display: flex; + height: ${(props) => (props.autoHeight ? "auto" : "100%")}; + min-height: ${(props) => (props.autoHeight ? "300px" : "unset")}; + + /* Main container styles */ + background: ${(props) => props.style?.background || "transparent"}; + margin: ${(props) => props.style?.margin || "0"}; + padding: ${(props) => props.style?.padding || "0"}; + border: ${(props) => props.style?.borderWidth || "0"} ${(props) => props.style?.borderStyle || "solid"} ${(props) => props.style?.border || "transparent"}; + border-radius: ${(props) => props.style?.radius || "0"}; + + /* Animation styles */ + animation: ${(props) => props.animationStyle?.animation || "none"}; + animation-duration: ${(props) => props.animationStyle?.animationDuration || "0s"}; + animation-delay: ${(props) => props.animationStyle?.animationDelay || "0s"}; + animation-iteration-count: ${(props) => props.animationStyle?.animationIterationCount || "1"}; + + p { + margin: 0; + } + + /* Sidebar Styles */ + .aui-thread-list-root { + width: ${(props) => props.sidebarWidth || "250px"}; + background-color: ${(props) => props.sidebarStyle?.sidebarBackground || "#fff"}; + padding: 10px; + } + + .aui-thread-list-item-title { + color: ${(props) => props.sidebarStyle?.threadText || "inherit"}; + } + + /* Messages Window Styles */ + .aui-thread-root { + flex: 1; + background-color: ${(props) => props.messagesStyle?.messagesBackground || "#f9fafb"}; + height: auto; + } + + /* User Message Styles */ + .aui-user-message-content { + background-color: ${(props) => props.messagesStyle?.userMessageBackground || "#3b82f6"}; + color: ${(props) => props.messagesStyle?.userMessageText || "#ffffff"}; + } + + /* Assistant Message Styles */ + .aui-assistant-message-content { + background-color: ${(props) => props.messagesStyle?.assistantMessageBackground || "#ffffff"}; + color: ${(props) => props.messagesStyle?.assistantMessageText || "inherit"}; + } + + /* Input Field Styles */ + .aui-composer-input { + background-color: ${(props) => props.inputStyle?.inputBackground || "#ffffff"}; + color: ${(props) => props.inputStyle?.inputText || "inherit"}; + border-color: ${(props) => props.inputStyle?.inputBorder || "#d1d5db"}; + } + + /* Send Button Styles */ + .aui-composer-send { + background-color: ${(props) => props.sendButtonStyle?.sendButtonBackground || "#3b82f6"} !important; + + svg { + color: ${(props) => props.sendButtonStyle?.sendButtonIcon || "#ffffff"}; + } + } + + /* New Thread Button Styles */ + .aui-thread-list-root > button { + background-color: ${(props) => props.newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + color: ${(props) => props.newThreadButtonStyle?.newThreadText || "#ffffff"} !important; + border-color: ${(props) => props.newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + } + + /* Thread item styling */ + .aui-thread-list-item { + cursor: pointer; + transition: background-color 0.2s ease; + background-color: ${(props) => props.threadItemStyle?.threadItemBackground || "transparent"}; + color: ${(props) => props.threadItemStyle?.threadItemText || "inherit"}; + border: 1px solid ${(props) => props.threadItemStyle?.threadItemBorder || "transparent"}; + + &[data-active="true"] { + background-color: ${(props) => props.threadItemStyle?.activeThreadBackground || "#dbeafe"}; + color: ${(props) => props.threadItemStyle?.activeThreadText || "inherit"}; + border: 1px solid ${(props) => props.threadItemStyle?.activeThreadBorder || "#bfdbfe"}; + } + } +`; + +// ============================================================================ +// CHAT CONTAINER - DIRECT RENDERING LIKE ButtonView +// ============================================================================ + +const generateId = () => Math.random().toString(36).substr(2, 9); + +function ChatContainerView(props: ChatCoreProps) { + const { state, actions } = useChatContext(); + const [isRunning, setIsRunning] = useState(false); + + const currentMessages = actions.getCurrentMessages(); + + useEffect(() => { + if (currentMessages.length > 0 && !isRunning) { + props.onConversationUpdate?.(currentMessages); + } + }, [currentMessages, isRunning, props.onConversationUpdate]); + + useEffect(() => { + props.onEvent?.("componentLoad"); + }, [props.onEvent]); + + const convertMessage = (message: ChatMessage): ThreadMessageLike => { + const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; + + if (message.attachments && message.attachments.length > 0) { + for (const attachment of message.attachments) { + if (attachment.content) { + content.push(...attachment.content); + } + } + } + + return { + role: message.role, + content, + id: message.id, + createdAt: new Date(message.timestamp), + ...(message.attachments && message.attachments.length > 0 && { attachments: message.attachments }), + }; + }; + + const onNew = async (message: AppendMessage) => { + const textPart = (message.content as ThreadUserContentPart[]).find( + (part): part is TextContentPart => part.type === "text" + ); + + const text = textPart?.text?.trim() ?? ""; + const completeAttachments = (message.attachments ?? []).filter( + (att): att is CompleteAttachment => att.status.type === "complete" + ); + + if (!text && !completeAttachments.length) { + throw new Error("Cannot send an empty message"); + } + + const userMessage: ChatMessage = { + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + attachments: completeAttachments, + }; + + await actions.addMessage(state.currentThreadId, userMessage); + setIsRunning(true); + + try { + const response = await props.messageHandler.sendMessage(userMessage); + props.onMessageUpdate?.(userMessage.text); + + const assistantMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }; + + await actions.addMessage(state.currentThreadId, assistantMessage); + } catch (error) { + await actions.addMessage(state.currentThreadId, { + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }); + } finally { + setIsRunning(false); + } + }; + + const onEdit = async (message: AppendMessage) => { + const textPart = (message.content as ThreadUserContentPart[]).find( + (part): part is TextContentPart => part.type === "text" + ); + + const text = textPart?.text?.trim() ?? ""; + const completeAttachments = (message.attachments ?? []).filter( + (att): att is CompleteAttachment => att.status.type === "complete" + ); + + if (!text && !completeAttachments.length) { + throw new Error("Cannot send an empty message"); + } + + const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; + const newMessages = [...currentMessages.slice(0, index)]; + + const editedMessage: ChatMessage = { + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + attachments: completeAttachments, + }; + + newMessages.push(editedMessage); + await actions.updateMessages(state.currentThreadId, newMessages); + setIsRunning(true); + + try { + const response = await props.messageHandler.sendMessage(editedMessage); + props.onMessageUpdate?.(editedMessage.text); + + const assistantMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }; + + newMessages.push(assistantMessage); + await actions.updateMessages(state.currentThreadId, newMessages); + } catch (error) { + newMessages.push({ + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }); + await actions.updateMessages(state.currentThreadId, newMessages); + } finally { + setIsRunning(false); + } + }; + + const threadListAdapter: ExternalStoreThreadListAdapter = { + threadId: state.currentThreadId, + threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"), + archivedThreads: state.threadList.filter((t): t is ArchivedThreadData => t.status === "archived"), + + onSwitchToNewThread: async () => { + const threadId = await actions.createThread(trans("chat.newChatTitle")); + actions.setCurrentThread(threadId); + props.onEvent?.("threadCreated"); + }, + + onSwitchToThread: (threadId) => { + actions.setCurrentThread(threadId); + }, + + onRename: async (threadId, newTitle) => { + await actions.updateThread(threadId, { title: newTitle }); + props.onEvent?.("threadUpdated"); + }, + + onArchive: async (threadId) => { + await actions.updateThread(threadId, { status: "archived" }); + props.onEvent?.("threadUpdated"); + }, + + onDelete: async (threadId) => { + await actions.deleteThread(threadId); + props.onEvent?.("threadDeleted"); + }, + }; + + const runtime = useExternalStoreRuntime({ + messages: currentMessages, + setMessages: (messages) => actions.updateMessages(state.currentThreadId, messages), + convertMessage, + isRunning, + onNew, + onEdit, + adapters: { + threadList: threadListAdapter, + attachments: universalAttachmentAdapter, + }, + }); + + if (!state.isInitialized) { + return
Loading...
; + } + + return ( + + + + + + + ); +} + +// ============================================================================ +// EXPORT - WITH PROVIDERS (LIKE BUTTON/INPUT PATTERN) +// ============================================================================ + +export function ChatContainer(props: ChatCoreProps) { + return ( + + + + + + ); +} diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx deleted file mode 100644 index 0a50ccae9..000000000 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx - -import React from "react"; -import { ChatProvider } from "./context/ChatContext"; -import { ChatCoreMain } from "./ChatCoreMain"; -import { ChatCoreProps } from "../types/chatTypes"; -import { TooltipProvider } from "@radix-ui/react-tooltip"; - -// ============================================================================ -// CHAT CORE - THE SHARED FOUNDATION -// ============================================================================ - -export function ChatCore({ - storage, - messageHandler, - placeholder, - autoHeight, - sidebarWidth, - onMessageUpdate, - onConversationUpdate, - onEvent, - style, - sidebarStyle, - messagesStyle, - inputStyle, - sendButtonStyle, - newThreadButtonStyle, - threadItemStyle, - animationStyle -}: ChatCoreProps) { - return ( - - - - - - ); -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx deleted file mode 100644 index ec0f1b534..000000000 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ /dev/null @@ -1,420 +0,0 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx - -import React, { useState, useEffect } from "react"; -import { - useExternalStoreRuntime, - ThreadMessageLike, - AppendMessage, - AssistantRuntimeProvider, - ExternalStoreThreadListAdapter, - CompleteAttachment, - TextContentPart, - ThreadUserContentPart -} from "@assistant-ui/react"; -import { Thread } from "./assistant-ui/thread"; -import { ThreadList } from "./assistant-ui/thread-list"; -import { - useChatContext, - RegularThreadData, - ArchivedThreadData -} from "./context/ChatContext"; -import { MessageHandler, ChatMessage } from "../types/chatTypes"; -import styled from "styled-components"; -import { trans } from "i18n"; -import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; - -// ============================================================================ -// STYLED COMPONENTS (same as your current ChatMain) -// ============================================================================ - -const ChatContainer = styled.div<{ - $autoHeight?: boolean; - $sidebarWidth?: string; - $style?: any; - $sidebarStyle?: any; - $messagesStyle?: any; - $inputStyle?: any; - $sendButtonStyle?: any; - $newThreadButtonStyle?: any; - $threadItemStyle?: any; - $animationStyle?: any; -}>` - display: flex; - height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; - min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; - - /* Main container styles */ - background: ${(props) => props.$style?.background || "transparent"}; - margin: ${(props) => props.$style?.margin || "0"}; - padding: ${(props) => props.$style?.padding || "0"}; - border: ${(props) => props.$style?.borderWidth || "0"} ${(props) => props.$style?.borderStyle || "solid"} ${(props) => props.$style?.border || "transparent"}; - border-radius: ${(props) => props.$style?.radius || "0"}; - - /* Animation styles */ - animation: ${(props) => props.$animationStyle?.animation || "none"}; - animation-duration: ${(props) => props.$animationStyle?.animationDuration || "0s"}; - animation-delay: ${(props) => props.$animationStyle?.animationDelay || "0s"}; - animation-iteration-count: ${(props) => props.$animationStyle?.animationIterationCount || "1"}; - - p { - margin: 0; - } - - /* Sidebar Styles */ - .aui-thread-list-root { - width: ${(props) => props.$sidebarWidth || "250px"}; - background-color: ${(props) => props.$sidebarStyle?.sidebarBackground || "#fff"}; - padding: 10px; - } - - .aui-thread-list-item-title { - color: ${(props) => props.$sidebarStyle?.threadText || "inherit"}; - } - - /* Messages Window Styles */ - .aui-thread-root { - flex: 1; - background-color: ${(props) => props.$messagesStyle?.messagesBackground || "#f9fafb"}; - height: auto; - } - - /* User Message Styles */ - .aui-user-message-content { - background-color: ${(props) => props.$messagesStyle?.userMessageBackground || "#3b82f6"}; - color: ${(props) => props.$messagesStyle?.userMessageText || "#ffffff"}; - } - - /* Assistant Message Styles */ - .aui-assistant-message-content { - background-color: ${(props) => props.$messagesStyle?.assistantMessageBackground || "#ffffff"}; - color: ${(props) => props.$messagesStyle?.assistantMessageText || "inherit"}; - } - - /* Input Field Styles */ - .aui-composer-input { - background-color: ${(props) => props.$inputStyle?.inputBackground || "#ffffff"}; - color: ${(props) => props.$inputStyle?.inputText || "inherit"}; - border-color: ${(props) => props.$inputStyle?.inputBorder || "#d1d5db"}; - } - - /* Send Button Styles */ - .aui-composer-send { - background-color: ${(props) => props.$sendButtonStyle?.sendButtonBackground || "#3b82f6"} !important; - - svg { - color: ${(props) => props.$sendButtonStyle?.sendButtonIcon || "#ffffff"}; - } - } - - /* New Thread Button Styles */ - .aui-thread-list-root > button { - background-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; - color: ${(props) => props.$newThreadButtonStyle?.newThreadText || "#ffffff"} !important; - border-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; - } - - /* Thread item styling */ - .aui-thread-list-item { - cursor: pointer; - transition: background-color 0.2s ease; - background-color: ${(props) => props.$threadItemStyle?.threadItemBackground || "transparent"}; - color: ${(props) => props.$threadItemStyle?.threadItemText || "inherit"}; - border: 1px solid ${(props) => props.$threadItemStyle?.threadItemBorder || "transparent"}; - - &[data-active="true"] { - background-color: ${(props) => props.$threadItemStyle?.activeThreadBackground || "#dbeafe"}; - color: ${(props) => props.$threadItemStyle?.activeThreadText || "inherit"}; - border: 1px solid ${(props) => props.$threadItemStyle?.activeThreadBorder || "#bfdbfe"}; - } - } -`; - -// ============================================================================ -// CHAT CORE MAIN - FOR MAIN COMPONENT WITH FULL STYLING SUPPORT -// (Bottom panel uses ChatPanelCore instead - see ChatPanelCore.tsx) -// ============================================================================ - -interface ChatCoreMainProps { - messageHandler: MessageHandler; - placeholder?: string; - autoHeight?: boolean; - sidebarWidth?: string; - onMessageUpdate?: (message: string) => void; - onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; - // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK (OPTIONAL) - onEvent?: (eventName: string) => void; - // Style controls - style?: any; - sidebarStyle?: any; - messagesStyle?: any; - inputStyle?: any; - sendButtonStyle?: any; - newThreadButtonStyle?: any; - threadItemStyle?: any; - animationStyle?: any; -} - -const generateId = () => Math.random().toString(36).substr(2, 9); - -export function ChatCoreMain({ - messageHandler, - placeholder, - autoHeight, - sidebarWidth, - onMessageUpdate, - onConversationUpdate, - onEvent, - style, - sidebarStyle, - messagesStyle, - inputStyle, - sendButtonStyle, - newThreadButtonStyle, - threadItemStyle, - animationStyle -}: ChatCoreMainProps) { - const { state, actions } = useChatContext(); - const [isRunning, setIsRunning] = useState(false); - - console.log("RENDERING CHAT CORE MAIN"); - - // Get messages for current thread - const currentMessages = actions.getCurrentMessages(); - - // Notify parent component of conversation changes - OPTIMIZED TIMING - useEffect(() => { - // Only update conversationHistory when we have complete conversations - // Skip empty states and intermediate processing states - if (currentMessages.length > 0 && !isRunning) { - onConversationUpdate?.(currentMessages); - } - }, [currentMessages, isRunning]); - - // Trigger component load event on mount - useEffect(() => { - onEvent?.("componentLoad"); - }, [onEvent]); - - // Convert custom format to ThreadMessageLike (same as your current implementation) - const convertMessage = (message: ChatMessage): ThreadMessageLike => { - const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; - - // Add attachment content if attachments exist - if (message.attachments && message.attachments.length > 0) { - for (const attachment of message.attachments) { - if (attachment.content) { - content.push(...attachment.content); - } - } - } - - return { - role: message.role, - content, - id: message.id, - createdAt: new Date(message.timestamp), - ...(message.attachments && message.attachments.length > 0 && { attachments: message.attachments }), - }; - }; - - // Handle new message - MUCH CLEANER with messageHandler - const onNew = async (message: AppendMessage) => { - const textPart = (message.content as ThreadUserContentPart[]).find( - (part): part is TextContentPart => part.type === "text" - ); - - const text = textPart?.text?.trim() ?? ""; - - const completeAttachments = (message.attachments ?? []).filter( - (att): att is CompleteAttachment => att.status.type === "complete" - ); - - const hasText = text.length > 0; - const hasAttachments = completeAttachments.length > 0; - - if (!hasText && !hasAttachments) { - throw new Error("Cannot send an empty message"); - } - - const userMessage: ChatMessage = { - id: generateId(), - role: "user", - text, - timestamp: Date.now(), - attachments: completeAttachments, - }; - - await actions.addMessage(state.currentThreadId, userMessage); - setIsRunning(true); - - try { - const response = await messageHandler.sendMessage(userMessage); // Send full message object with attachments - - onMessageUpdate?.(userMessage.text); - - const assistantMessage: ChatMessage = { - id: generateId(), - role: "assistant", - text: response.content, - timestamp: Date.now(), - }; - - await actions.addMessage(state.currentThreadId, assistantMessage); - } catch (error) { - const errorMessage: ChatMessage = { - id: generateId(), - role: "assistant", - text: trans("chat.errorUnknown"), - timestamp: Date.now(), - }; - - await actions.addMessage(state.currentThreadId, errorMessage); - } finally { - setIsRunning(false); - } - }; - - - // Handle edit message - CLEANER with messageHandler - const onEdit = async (message: AppendMessage) => { - // Extract the first text content part (if any) - const textPart = (message.content as ThreadUserContentPart[]).find( - (part): part is TextContentPart => part.type === "text" - ); - - const text = textPart?.text?.trim() ?? ""; - - // Filter only complete attachments - const completeAttachments = (message.attachments ?? []).filter( - (att): att is CompleteAttachment => att.status.type === "complete" - ); - - const hasText = text.length > 0; - const hasAttachments = completeAttachments.length > 0; - - if (!hasText && !hasAttachments) { - throw new Error("Cannot send an empty message"); - } - - // Find the index of the message being edited - const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; - - // Build a new messages array: messages up to and including the one being edited - const newMessages = [...currentMessages.slice(0, index)]; - - // Build the edited user message - const editedMessage: ChatMessage = { - id: generateId(), - role: "user", - text, - timestamp: Date.now(), - attachments: completeAttachments, - }; - - newMessages.push(editedMessage); - - // Update state with edited context - await actions.updateMessages(state.currentThreadId, newMessages); - setIsRunning(true); - - try { - const response = await messageHandler.sendMessage(editedMessage); // Send full message object with attachments - - onMessageUpdate?.(editedMessage.text); - - const assistantMessage: ChatMessage = { - id: generateId(), - role: "assistant", - text: response.content, - timestamp: Date.now(), - }; - - newMessages.push(assistantMessage); - await actions.updateMessages(state.currentThreadId, newMessages); - } catch (error) { - const errorMessage: ChatMessage = { - id: generateId(), - role: "assistant", - text: trans("chat.errorUnknown"), - timestamp: Date.now(), - }; - - newMessages.push(errorMessage); - await actions.updateMessages(state.currentThreadId, newMessages); - } finally { - setIsRunning(false); - } - }; - - // Thread list adapter for managing multiple threads (same as your current implementation) - const threadListAdapter: ExternalStoreThreadListAdapter = { - threadId: state.currentThreadId, - threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"), - archivedThreads: state.threadList.filter((t): t is ArchivedThreadData => t.status === "archived"), - - onSwitchToNewThread: async () => { - const threadId = await actions.createThread(trans("chat.newChatTitle")); - actions.setCurrentThread(threadId); - onEvent?.("threadCreated"); - }, - - onSwitchToThread: (threadId) => { - actions.setCurrentThread(threadId); - }, - - onRename: async (threadId, newTitle) => { - await actions.updateThread(threadId, { title: newTitle }); - onEvent?.("threadUpdated"); - }, - - onArchive: async (threadId) => { - await actions.updateThread(threadId, { status: "archived" }); - onEvent?.("threadUpdated"); - }, - - onDelete: async (threadId) => { - await actions.deleteThread(threadId); - onEvent?.("threadDeleted"); - }, - }; - - const runtime = useExternalStoreRuntime({ - messages: currentMessages, - setMessages: (messages) => { - actions.updateMessages(state.currentThreadId, messages); - }, - convertMessage, - isRunning, - onNew, - onEdit, - adapters: { - threadList: threadListAdapter, - attachments: universalAttachmentAdapter, - }, - }); - - if (!state.isInitialized) { - return
Loading...
; - } - - return ( - - - - - - - ); -} - diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx index a0586b67a..78f65eb54 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx @@ -1,19 +1,17 @@ // client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx import { useMemo } from "react"; -import { ChatProvider } from "./context/ChatContext"; -import { ChatPanelCore } from "./ChatPanelCore"; +import { ChatPanelContainer } from "./ChatPanelContainer"; import { createChatStorage } from "../utils/storageFactory"; import { N8NHandler } from "../handlers/messageHandlers"; import { ChatPanelProps } from "../types/chatTypes"; import { trans } from "i18n"; -import { TooltipProvider } from "@radix-ui/react-tooltip"; import "@assistant-ui/styles/index.css"; import "@assistant-ui/styles/markdown.css"; // ============================================================================ -// CHAT PANEL - SIMPLIFIED BOTTOM PANEL COMPONENT (NO STYLING CONTROLS) +// CHAT PANEL - SIMPLIFIED BOTTOM PANEL (NO STYLING CONTROLS) // ============================================================================ export function ChatPanel({ @@ -23,13 +21,11 @@ export function ChatPanel({ streaming = true, onMessageUpdate }: ChatPanelProps) { - // Create storage instance const storage = useMemo(() => createChatStorage(tableName), [tableName] ); - // Create N8N message handler const messageHandler = useMemo(() => new N8NHandler({ modelHost, @@ -40,13 +36,10 @@ export function ChatPanel({ ); return ( - - - - - + ); } \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx similarity index 71% rename from client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelCore.tsx rename to client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx index f0978e56b..612afc32e 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelCore.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx @@ -1,4 +1,4 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelCore.tsx +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx import React, { useState, useEffect } from "react"; import { @@ -14,6 +14,7 @@ import { import { Thread } from "./assistant-ui/thread"; import { ThreadList } from "./assistant-ui/thread-list"; import { + ChatProvider, useChatContext, RegularThreadData, ArchivedThreadData @@ -22,25 +23,29 @@ import { MessageHandler, ChatMessage } from "../types/chatTypes"; import styled from "styled-components"; import { trans } from "i18n"; import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; + +import "@assistant-ui/styles/index.css"; +import "@assistant-ui/styles/markdown.css"; // ============================================================================ -// SIMPLE STYLED COMPONENTS - FIXED STYLING FOR BOTTOM PANEL +// STYLED CONTAINER - SIMPLE FIXED STYLING FOR BOTTOM PANEL // ============================================================================ -const ChatContainer = styled.div<{ - $autoHeight?: boolean; - $sidebarWidth?: string; +const StyledChatContainer = styled.div<{ + autoHeight?: boolean; + sidebarWidth?: string; }>` display: flex; - height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; - min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; + height: ${(props) => (props.autoHeight ? "auto" : "100%")}; + min-height: ${(props) => (props.autoHeight ? "300px" : "unset")}; p { margin: 0; } .aui-thread-list-root { - width: ${(props) => props.$sidebarWidth || "250px"}; + width: ${(props) => props.sidebarWidth || "250px"}; background-color: #fff; padding: 10px; } @@ -63,49 +68,24 @@ const ChatContainer = styled.div<{ `; // ============================================================================ -// CHAT PANEL CORE - SIMPLIFIED FOR BOTTOM PANEL (NO STYLING PROPS) +// CHAT PANEL CONTAINER - DIRECT RENDERING // ============================================================================ -interface ChatPanelCoreProps { +const generateId = () => Math.random().toString(36).substr(2, 9); + +export interface ChatPanelContainerProps { + storage: any; messageHandler: MessageHandler; placeholder?: string; - autoHeight?: boolean; - sidebarWidth?: string; onMessageUpdate?: (message: string) => void; - onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; - onEvent?: (eventName: string) => void; } -const generateId = () => Math.random().toString(36).substr(2, 9); - -export function ChatPanelCore({ - messageHandler, - placeholder, - autoHeight, - sidebarWidth, - onMessageUpdate, - onConversationUpdate, - onEvent -}: ChatPanelCoreProps) { +function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit) { const { state, actions } = useChatContext(); const [isRunning, setIsRunning] = useState(false); - // Get messages for current thread const currentMessages = actions.getCurrentMessages(); - // Notify parent component of conversation changes - useEffect(() => { - if (currentMessages.length > 0 && !isRunning) { - onConversationUpdate?.(currentMessages); - } - }, [currentMessages, isRunning]); - - // Trigger component load event on mount - useEffect(() => { - onEvent?.("componentLoad"); - }, [onEvent]); - - // Convert custom format to ThreadMessageLike const convertMessage = (message: ChatMessage): ThreadMessageLike => { const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; @@ -126,22 +106,17 @@ export function ChatPanelCore({ }; }; - // Handle new message const onNew = async (message: AppendMessage) => { const textPart = (message.content as ThreadUserContentPart[]).find( (part): part is TextContentPart => part.type === "text" ); const text = textPart?.text?.trim() ?? ""; - const completeAttachments = (message.attachments ?? []).filter( (att): att is CompleteAttachment => att.status.type === "complete" ); - const hasText = text.length > 0; - const hasAttachments = completeAttachments.length > 0; - - if (!hasText && !hasAttachments) { + if (!text && !completeAttachments.length) { throw new Error("Cannot send an empty message"); } @@ -158,95 +133,78 @@ export function ChatPanelCore({ try { const response = await messageHandler.sendMessage(userMessage); - onMessageUpdate?.(userMessage.text); - const assistantMessage: ChatMessage = { + await actions.addMessage(state.currentThreadId, { id: generateId(), role: "assistant", text: response.content, timestamp: Date.now(), - }; - - await actions.addMessage(state.currentThreadId, assistantMessage); + }); } catch (error) { - const errorMessage: ChatMessage = { + await actions.addMessage(state.currentThreadId, { id: generateId(), role: "assistant", text: trans("chat.errorUnknown"), timestamp: Date.now(), - }; - - await actions.addMessage(state.currentThreadId, errorMessage); + }); } finally { setIsRunning(false); } }; - // Handle edit message const onEdit = async (message: AppendMessage) => { const textPart = (message.content as ThreadUserContentPart[]).find( (part): part is TextContentPart => part.type === "text" ); const text = textPart?.text?.trim() ?? ""; - const completeAttachments = (message.attachments ?? []).filter( (att): att is CompleteAttachment => att.status.type === "complete" ); - const hasText = text.length > 0; - const hasAttachments = completeAttachments.length > 0; - - if (!hasText && !hasAttachments) { + if (!text && !completeAttachments.length) { throw new Error("Cannot send an empty message"); } const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; const newMessages = [...currentMessages.slice(0, index)]; - const editedMessage: ChatMessage = { + newMessages.push({ id: generateId(), role: "user", text, timestamp: Date.now(), attachments: completeAttachments, - }; + }); - newMessages.push(editedMessage); await actions.updateMessages(state.currentThreadId, newMessages); setIsRunning(true); try { - const response = await messageHandler.sendMessage(editedMessage); + const response = await messageHandler.sendMessage(newMessages[newMessages.length - 1]); + onMessageUpdate?.(text); - onMessageUpdate?.(editedMessage.text); - - const assistantMessage: ChatMessage = { + newMessages.push({ id: generateId(), role: "assistant", text: response.content, timestamp: Date.now(), - }; - - newMessages.push(assistantMessage); + }); await actions.updateMessages(state.currentThreadId, newMessages); } catch (error) { - const errorMessage: ChatMessage = { + newMessages.push({ id: generateId(), role: "assistant", text: trans("chat.errorUnknown"), timestamp: Date.now(), - }; - - newMessages.push(errorMessage); + }); await actions.updateMessages(state.currentThreadId, newMessages); } finally { setIsRunning(false); } }; - // Thread list adapter const threadListAdapter: ExternalStoreThreadListAdapter = { threadId: state.currentThreadId, threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"), @@ -255,7 +213,6 @@ export function ChatPanelCore({ onSwitchToNewThread: async () => { const threadId = await actions.createThread(trans("chat.newChatTitle")); actions.setCurrentThread(threadId); - onEvent?.("threadCreated"); }, onSwitchToThread: (threadId) => { @@ -264,25 +221,20 @@ export function ChatPanelCore({ onRename: async (threadId, newTitle) => { await actions.updateThread(threadId, { title: newTitle }); - onEvent?.("threadUpdated"); }, onArchive: async (threadId) => { await actions.updateThread(threadId, { status: "archived" }); - onEvent?.("threadUpdated"); }, onDelete: async (threadId) => { await actions.deleteThread(threadId); - onEvent?.("threadDeleted"); }, }; const runtime = useExternalStoreRuntime({ messages: currentMessages, - setMessages: (messages) => { - actions.updateMessages(state.currentThreadId, messages); - }, + setMessages: (messages) => actions.updateMessages(state.currentThreadId, messages), convertMessage, isRunning, onNew, @@ -299,10 +251,28 @@ export function ChatPanelCore({ return ( - + - + ); } + +// ============================================================================ +// EXPORT - WITH PROVIDERS +// ============================================================================ + +export function ChatPanelContainer({ storage, messageHandler, placeholder, onMessageUpdate }: ChatPanelContainerProps) { + return ( + + + + + + ); +} From e3fe29839df3905fc586b60ab9224c6d7942e9c6 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Mon, 16 Feb 2026 17:34:49 +0500 Subject: [PATCH 07/34] fix re rendering issue --- .../chatComp/components/ChatContainer.tsx | 20 +++++++++++++------ .../components/context/ChatContext.tsx | 2 ++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx index dc4c35b2d..5e884bb12 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx @@ -1,6 +1,6 @@ // client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useExternalStoreRuntime, ThreadMessageLike, @@ -83,7 +83,7 @@ const StyledChatContainer = styled.div` } /* Input Field Styles */ - .aui-composer-input { + form.aui-composer-root { background-color: ${(props) => props.inputStyle?.inputBackground || "#ffffff"}; color: ${(props) => props.inputStyle?.inputText || "inherit"}; border-color: ${(props) => props.inputStyle?.inputBorder || "#d1d5db"}; @@ -131,17 +131,25 @@ function ChatContainerView(props: ChatCoreProps) { const { state, actions } = useChatContext(); const [isRunning, setIsRunning] = useState(false); + // Store callback props in refs so useEffects don't re-fire + // when Lowcoder's builder creates new function references on each render + const onConversationUpdateRef = useRef(props.onConversationUpdate); + onConversationUpdateRef.current = props.onConversationUpdate; + + const onEventRef = useRef(props.onEvent); + onEventRef.current = props.onEvent; + const currentMessages = actions.getCurrentMessages(); useEffect(() => { if (currentMessages.length > 0 && !isRunning) { - props.onConversationUpdate?.(currentMessages); + onConversationUpdateRef.current?.(currentMessages); } - }, [currentMessages, isRunning, props.onConversationUpdate]); + }, [currentMessages, isRunning]); useEffect(() => { - props.onEvent?.("componentLoad"); - }, [props.onEvent]); + onEventRef.current?.("componentLoad"); + }, []); const convertMessage = (message: ChatMessage): ThreadMessageLike => { const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx index 1a31222a9..e733727f3 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx @@ -360,7 +360,9 @@ export function ChatProvider({ children, storage }: { // Auto-initialize on mount useEffect(() => { + console.log("useEffect Inside ChatProvider", state.isInitialized, state.isLoading); if (!state.isInitialized && !state.isLoading) { + console.log("Initializing chat data..."); initialize(); } }, [state.isInitialized, state.isLoading]); From c970bc2a84556de9275002ea2532d93cf9ba1d42 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Mon, 16 Feb 2026 23:30:47 +0500 Subject: [PATCH 08/34] fix button forward ref + ChatProvider level --- .../src/comps/comps/chatComp/chatComp.tsx | 27 +++++++++++-------- .../chatComp/components/ChatContainer.tsx | 16 +++-------- .../comps/chatComp/components/ui/button.tsx | 22 +++++++-------- .../comps/comps/chatComp/types/chatTypes.ts | 1 - 4 files changed, 30 insertions(+), 36 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index dc1cfa1fc..b923e3aab 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -12,6 +12,7 @@ import QuerySelectControl from "comps/controls/querySelectControl"; import { eventHandlerControl, EventConfigType } from "comps/controls/eventHandlerControl"; import { AutoHeightControl } from "comps/controls/autoHeightControl"; import { ChatContainer } from "./components/ChatContainer"; +import { ChatProvider } from "./components/context/ChatContext"; import { ChatPropertyView } from "./chatPropertyView"; import { createChatStorage } from "./utils/storageFactory"; import { QueryHandler, createMessageHandler } from "./handlers/messageHandlers"; @@ -19,6 +20,7 @@ import { useMemo, useRef, useEffect } from "react"; import { changeChildAction } from "lowcoder-core"; import { ChatMessage } from "./types/chatTypes"; import { trans } from "i18n"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; import { styleControl } from "comps/controls/styleControl"; import { ChatStyle, @@ -302,17 +304,20 @@ const ChatTmpComp = new UICompBuilder( }; return ( - + + + + + ); } ) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx index 5e884bb12..f6577ff1f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx @@ -14,7 +14,6 @@ import { import { Thread } from "./assistant-ui/thread"; import { ThreadList } from "./assistant-ui/thread-list"; import { - ChatProvider, useChatContext, RegularThreadData, ArchivedThreadData @@ -23,7 +22,6 @@ import { MessageHandler, ChatMessage, ChatCoreProps } from "../types/chatTypes"; import styled from "styled-components"; import { trans } from "i18n"; import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; -import { TooltipProvider } from "@radix-ui/react-tooltip"; // ============================================================================ // STYLED CONTAINER - FOLLOWING LOWCODER PATTERNS @@ -122,7 +120,7 @@ const StyledChatContainer = styled.div` `; // ============================================================================ -// CHAT CONTAINER - DIRECT RENDERING LIKE ButtonView +// CHAT CONTAINER - USES CONTEXT FROM CHATPROVIDER // ============================================================================ const generateId = () => Math.random().toString(36).substr(2, 9); @@ -334,15 +332,7 @@ function ChatContainerView(props: ChatCoreProps) { } // ============================================================================ -// EXPORT - WITH PROVIDERS (LIKE BUTTON/INPUT PATTERN) +// EXPORT - SIMPLIFIED (PROVIDERS MOVED UP ONE LEVEL) // ============================================================================ -export function ChatContainer(props: ChatCoreProps) { - return ( - - - - - - ); -} +export const ChatContainer = ChatContainerView; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx index 4406b74e6..945783c69 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx @@ -21,25 +21,25 @@ const buttonVariants = cva("aui-button", { }, }); -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean; - }) { +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + } +>(({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( ); -} +}); + +Button.displayName = "Button"; export { Button, buttonVariants }; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index 919094ba7..b465eda8b 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -73,7 +73,6 @@ export interface ChatMessage { // Main Chat Component Props (with full styling support) export interface ChatCoreProps { - storage: ChatStorage; messageHandler: MessageHandler; placeholder?: string; autoHeight?: boolean; From 64dddc06da6eab64a9dd9d615c4b08f69236dabf Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 17 Feb 2026 19:47:39 +0500 Subject: [PATCH 09/34] fix styling warninings --- .../chatComp/components/ChatContainer.tsx | 86 ++++++++++++------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx index f6577ff1f..6d6e8e04a 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx @@ -26,12 +26,27 @@ import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; // ============================================================================ // STYLED CONTAINER - FOLLOWING LOWCODER PATTERNS // Styles passed as objects, applied directly like Button100 and InputStyle +// Using $ prefix for transient props (props that shouldn't be passed to DOM) // ============================================================================ -const StyledChatContainer = styled.div` +// Transient props interface - these props are for styling only and won't be passed to DOM +interface StyledChatContainerProps { + $autoHeight?: boolean; + $sidebarWidth?: string; + $sidebarStyle?: any; + $messagesStyle?: any; + $inputStyle?: any; + $sendButtonStyle?: any; + $newThreadButtonStyle?: any; + $threadItemStyle?: any; + $animationStyle?: any; + style?: any; // This is a valid DOM attribute, no $ needed +} + +const StyledChatContainer = styled.div` display: flex; - height: ${(props) => (props.autoHeight ? "auto" : "100%")}; - min-height: ${(props) => (props.autoHeight ? "300px" : "unset")}; + height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; + min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; /* Main container styles */ background: ${(props) => props.style?.background || "transparent"}; @@ -41,10 +56,10 @@ const StyledChatContainer = styled.div` border-radius: ${(props) => props.style?.radius || "0"}; /* Animation styles */ - animation: ${(props) => props.animationStyle?.animation || "none"}; - animation-duration: ${(props) => props.animationStyle?.animationDuration || "0s"}; - animation-delay: ${(props) => props.animationStyle?.animationDelay || "0s"}; - animation-iteration-count: ${(props) => props.animationStyle?.animationIterationCount || "1"}; + animation: ${(props) => props.$animationStyle?.animation || "none"}; + animation-duration: ${(props) => props.$animationStyle?.animationDuration || "0s"}; + animation-delay: ${(props) => props.$animationStyle?.animationDelay || "0s"}; + animation-iteration-count: ${(props) => props.$animationStyle?.animationIterationCount || "1"}; p { margin: 0; @@ -52,69 +67,69 @@ const StyledChatContainer = styled.div` /* Sidebar Styles */ .aui-thread-list-root { - width: ${(props) => props.sidebarWidth || "250px"}; - background-color: ${(props) => props.sidebarStyle?.sidebarBackground || "#fff"}; + width: ${(props) => props.$sidebarWidth || "250px"}; + background-color: ${(props) => props.$sidebarStyle?.sidebarBackground || "#fff"}; padding: 10px; } .aui-thread-list-item-title { - color: ${(props) => props.sidebarStyle?.threadText || "inherit"}; + color: ${(props) => props.$sidebarStyle?.threadText || "inherit"}; } /* Messages Window Styles */ .aui-thread-root { flex: 1; - background-color: ${(props) => props.messagesStyle?.messagesBackground || "#f9fafb"}; + background-color: ${(props) => props.$messagesStyle?.messagesBackground || "#f9fafb"}; height: auto; } /* User Message Styles */ .aui-user-message-content { - background-color: ${(props) => props.messagesStyle?.userMessageBackground || "#3b82f6"}; - color: ${(props) => props.messagesStyle?.userMessageText || "#ffffff"}; + background-color: ${(props) => props.$messagesStyle?.userMessageBackground || "#3b82f6"}; + color: ${(props) => props.$messagesStyle?.userMessageText || "#ffffff"}; } /* Assistant Message Styles */ .aui-assistant-message-content { - background-color: ${(props) => props.messagesStyle?.assistantMessageBackground || "#ffffff"}; - color: ${(props) => props.messagesStyle?.assistantMessageText || "inherit"}; + background-color: ${(props) => props.$messagesStyle?.assistantMessageBackground || "#ffffff"}; + color: ${(props) => props.$messagesStyle?.assistantMessageText || "inherit"}; } /* Input Field Styles */ form.aui-composer-root { - background-color: ${(props) => props.inputStyle?.inputBackground || "#ffffff"}; - color: ${(props) => props.inputStyle?.inputText || "inherit"}; - border-color: ${(props) => props.inputStyle?.inputBorder || "#d1d5db"}; + background-color: ${(props) => props.$inputStyle?.inputBackground || "#ffffff"}; + color: ${(props) => props.$inputStyle?.inputText || "inherit"}; + border-color: ${(props) => props.$inputStyle?.inputBorder || "#d1d5db"}; } /* Send Button Styles */ .aui-composer-send { - background-color: ${(props) => props.sendButtonStyle?.sendButtonBackground || "#3b82f6"} !important; + background-color: ${(props) => props.$sendButtonStyle?.sendButtonBackground || "#3b82f6"} !important; svg { - color: ${(props) => props.sendButtonStyle?.sendButtonIcon || "#ffffff"}; + color: ${(props) => props.$sendButtonStyle?.sendButtonIcon || "#ffffff"}; } } /* New Thread Button Styles */ .aui-thread-list-root > button { - background-color: ${(props) => props.newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; - color: ${(props) => props.newThreadButtonStyle?.newThreadText || "#ffffff"} !important; - border-color: ${(props) => props.newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + background-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + color: ${(props) => props.$newThreadButtonStyle?.newThreadText || "#ffffff"} !important; + border-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; } /* Thread item styling */ .aui-thread-list-item { cursor: pointer; transition: background-color 0.2s ease; - background-color: ${(props) => props.threadItemStyle?.threadItemBackground || "transparent"}; - color: ${(props) => props.threadItemStyle?.threadItemText || "inherit"}; - border: 1px solid ${(props) => props.threadItemStyle?.threadItemBorder || "transparent"}; + background-color: ${(props) => props.$threadItemStyle?.threadItemBackground || "transparent"}; + color: ${(props) => props.$threadItemStyle?.threadItemText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.threadItemBorder || "transparent"}; &[data-active="true"] { - background-color: ${(props) => props.threadItemStyle?.activeThreadBackground || "#dbeafe"}; - color: ${(props) => props.threadItemStyle?.activeThreadText || "inherit"}; - border: 1px solid ${(props) => props.threadItemStyle?.activeThreadBorder || "#bfdbfe"}; + background-color: ${(props) => props.$threadItemStyle?.activeThreadBackground || "#dbeafe"}; + color: ${(props) => props.$threadItemStyle?.activeThreadText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.activeThreadBorder || "#bfdbfe"}; } } `; @@ -323,7 +338,18 @@ function ChatContainerView(props: ChatCoreProps) { return ( - + From 5ef44036cb4619608ecdb88d8e14cea93c1f7bac Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 17 Feb 2026 21:49:08 +0500 Subject: [PATCH 10/34] refactor styles and add storage cleaner for bottom chat panel --- .../chatComp/components/ChatContainer.tsx | 113 +----------------- .../components/ChatContainerStyles.ts | 108 +++++++++++++++++ .../comps/chatComp/components/ChatPanel.tsx | 19 ++- 3 files changed, 122 insertions(+), 118 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx index 6d6e8e04a..af8028b4d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx @@ -19,120 +19,9 @@ import { ArchivedThreadData } from "./context/ChatContext"; import { MessageHandler, ChatMessage, ChatCoreProps } from "../types/chatTypes"; -import styled from "styled-components"; import { trans } from "i18n"; import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; - -// ============================================================================ -// STYLED CONTAINER - FOLLOWING LOWCODER PATTERNS -// Styles passed as objects, applied directly like Button100 and InputStyle -// Using $ prefix for transient props (props that shouldn't be passed to DOM) -// ============================================================================ - -// Transient props interface - these props are for styling only and won't be passed to DOM -interface StyledChatContainerProps { - $autoHeight?: boolean; - $sidebarWidth?: string; - $sidebarStyle?: any; - $messagesStyle?: any; - $inputStyle?: any; - $sendButtonStyle?: any; - $newThreadButtonStyle?: any; - $threadItemStyle?: any; - $animationStyle?: any; - style?: any; // This is a valid DOM attribute, no $ needed -} - -const StyledChatContainer = styled.div` - display: flex; - height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; - min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; - - /* Main container styles */ - background: ${(props) => props.style?.background || "transparent"}; - margin: ${(props) => props.style?.margin || "0"}; - padding: ${(props) => props.style?.padding || "0"}; - border: ${(props) => props.style?.borderWidth || "0"} ${(props) => props.style?.borderStyle || "solid"} ${(props) => props.style?.border || "transparent"}; - border-radius: ${(props) => props.style?.radius || "0"}; - - /* Animation styles */ - animation: ${(props) => props.$animationStyle?.animation || "none"}; - animation-duration: ${(props) => props.$animationStyle?.animationDuration || "0s"}; - animation-delay: ${(props) => props.$animationStyle?.animationDelay || "0s"}; - animation-iteration-count: ${(props) => props.$animationStyle?.animationIterationCount || "1"}; - - p { - margin: 0; - } - - /* Sidebar Styles */ - .aui-thread-list-root { - width: ${(props) => props.$sidebarWidth || "250px"}; - background-color: ${(props) => props.$sidebarStyle?.sidebarBackground || "#fff"}; - padding: 10px; - } - - .aui-thread-list-item-title { - color: ${(props) => props.$sidebarStyle?.threadText || "inherit"}; - } - - /* Messages Window Styles */ - .aui-thread-root { - flex: 1; - background-color: ${(props) => props.$messagesStyle?.messagesBackground || "#f9fafb"}; - height: auto; - } - - /* User Message Styles */ - .aui-user-message-content { - background-color: ${(props) => props.$messagesStyle?.userMessageBackground || "#3b82f6"}; - color: ${(props) => props.$messagesStyle?.userMessageText || "#ffffff"}; - } - - /* Assistant Message Styles */ - .aui-assistant-message-content { - background-color: ${(props) => props.$messagesStyle?.assistantMessageBackground || "#ffffff"}; - color: ${(props) => props.$messagesStyle?.assistantMessageText || "inherit"}; - } - - /* Input Field Styles */ - form.aui-composer-root { - background-color: ${(props) => props.$inputStyle?.inputBackground || "#ffffff"}; - color: ${(props) => props.$inputStyle?.inputText || "inherit"}; - border-color: ${(props) => props.$inputStyle?.inputBorder || "#d1d5db"}; - } - - /* Send Button Styles */ - .aui-composer-send { - background-color: ${(props) => props.$sendButtonStyle?.sendButtonBackground || "#3b82f6"} !important; - - svg { - color: ${(props) => props.$sendButtonStyle?.sendButtonIcon || "#ffffff"}; - } - } - - /* New Thread Button Styles */ - .aui-thread-list-root > button { - background-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; - color: ${(props) => props.$newThreadButtonStyle?.newThreadText || "#ffffff"} !important; - border-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; - } - - /* Thread item styling */ - .aui-thread-list-item { - cursor: pointer; - transition: background-color 0.2s ease; - background-color: ${(props) => props.$threadItemStyle?.threadItemBackground || "transparent"}; - color: ${(props) => props.$threadItemStyle?.threadItemText || "inherit"}; - border: 1px solid ${(props) => props.$threadItemStyle?.threadItemBorder || "transparent"}; - - &[data-active="true"] { - background-color: ${(props) => props.$threadItemStyle?.activeThreadBackground || "#dbeafe"}; - color: ${(props) => props.$threadItemStyle?.activeThreadText || "inherit"}; - border: 1px solid ${(props) => props.$threadItemStyle?.activeThreadBorder || "#bfdbfe"}; - } - } -`; +import { StyledChatContainer } from "./ChatContainerStyles"; // ============================================================================ // CHAT CONTAINER - USES CONTEXT FROM CHATPROVIDER diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts new file mode 100644 index 000000000..1f2d4580d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts @@ -0,0 +1,108 @@ +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.styles.ts + +import styled from "styled-components"; + + +export interface StyledChatContainerProps { + $autoHeight?: boolean; + $sidebarWidth?: string; + $sidebarStyle?: any; + $messagesStyle?: any; + $inputStyle?: any; + $sendButtonStyle?: any; + $newThreadButtonStyle?: any; + $threadItemStyle?: any; + $animationStyle?: any; + style?: any; +} + +export const StyledChatContainer = styled.div` + display: flex; + height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; + min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; + + /* Main container styles */ + background: ${(props) => props.style?.background || "transparent"}; + margin: ${(props) => props.style?.margin || "0"}; + padding: ${(props) => props.style?.padding || "0"}; + border: ${(props) => props.style?.borderWidth || "0"} ${(props) => props.style?.borderStyle || "solid"} ${(props) => props.style?.border || "transparent"}; + border-radius: ${(props) => props.style?.radius || "0"}; + + /* Animation styles */ + animation: ${(props) => props.$animationStyle?.animation || "none"}; + animation-duration: ${(props) => props.$animationStyle?.animationDuration || "0s"}; + animation-delay: ${(props) => props.$animationStyle?.animationDelay || "0s"}; + animation-iteration-count: ${(props) => props.$animationStyle?.animationIterationCount || "1"}; + + p { + margin: 0; + } + + /* Sidebar Styles */ + .aui-thread-list-root { + width: ${(props) => props.$sidebarWidth || "250px"}; + background-color: ${(props) => props.$sidebarStyle?.sidebarBackground || "#fff"}; + padding: 10px; + } + + .aui-thread-list-item-title { + color: ${(props) => props.$sidebarStyle?.threadText || "inherit"}; + } + + /* Messages Window Styles */ + .aui-thread-root { + flex: 1; + background-color: ${(props) => props.$messagesStyle?.messagesBackground || "#f9fafb"}; + height: auto; + } + + /* User Message Styles */ + .aui-user-message-content { + background-color: ${(props) => props.$messagesStyle?.userMessageBackground || "#3b82f6"}; + color: ${(props) => props.$messagesStyle?.userMessageText || "#ffffff"}; + } + + /* Assistant Message Styles */ + .aui-assistant-message-content { + background-color: ${(props) => props.$messagesStyle?.assistantMessageBackground || "#ffffff"}; + color: ${(props) => props.$messagesStyle?.assistantMessageText || "inherit"}; + } + + /* Input Field Styles */ + form.aui-composer-root { + background-color: ${(props) => props.$inputStyle?.inputBackground || "#ffffff"}; + color: ${(props) => props.$inputStyle?.inputText || "inherit"}; + border-color: ${(props) => props.$inputStyle?.inputBorder || "#d1d5db"}; + } + + /* Send Button Styles */ + .aui-composer-send { + background-color: ${(props) => props.$sendButtonStyle?.sendButtonBackground || "#3b82f6"} !important; + + svg { + color: ${(props) => props.$sendButtonStyle?.sendButtonIcon || "#ffffff"}; + } + } + + /* New Thread Button Styles */ + .aui-thread-list-root > button { + background-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + color: ${(props) => props.$newThreadButtonStyle?.newThreadText || "#ffffff"} !important; + border-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + } + + /* Thread item styling */ + .aui-thread-list-item { + cursor: pointer; + transition: background-color 0.2s ease; + background-color: ${(props) => props.$threadItemStyle?.threadItemBackground || "transparent"}; + color: ${(props) => props.$threadItemStyle?.threadItemText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.threadItemBorder || "transparent"}; + + &[data-active="true"] { + background-color: ${(props) => props.$threadItemStyle?.activeThreadBackground || "#dbeafe"}; + color: ${(props) => props.$threadItemStyle?.activeThreadText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.activeThreadBorder || "#bfdbfe"}; + } + } +`; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx index 78f65eb54..f4823011e 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx @@ -1,6 +1,6 @@ // client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx -import { useMemo } from "react"; +import { useMemo, useEffect } from "react"; import { ChatPanelContainer } from "./ChatPanelContainer"; import { createChatStorage } from "../utils/storageFactory"; import { N8NHandler } from "../handlers/messageHandlers"; @@ -21,20 +21,27 @@ export function ChatPanel({ streaming = true, onMessageUpdate }: ChatPanelProps) { - const storage = useMemo(() => - createChatStorage(tableName), + const storage = useMemo(() => + createChatStorage(tableName), [tableName] ); - - const messageHandler = useMemo(() => + + const messageHandler = useMemo(() => new N8NHandler({ modelHost, systemPrompt, streaming - }), + }), [modelHost, systemPrompt, streaming] ); + // Cleanup on unmount - delete chat data from storage + useEffect(() => { + return () => { + storage.cleanup(); + }; + }, [storage]); + return ( Date: Wed, 18 Feb 2026 18:08:17 +0500 Subject: [PATCH 11/34] fix attachment file adaptor for chat component --- .../comps/chatComp/utils/attachmentAdapter.ts | 65 +++++++++---------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts index a0f7c78e0..a70afc734 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts @@ -6,24 +6,16 @@ import type { ThreadUserContentPart } from "@assistant-ui/react"; + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + export const universalAttachmentAdapter: AttachmentAdapter = { accept: "*/*", async add({ file }): Promise { - const MAX_SIZE = 10 * 1024 * 1024; - - if (file.size > MAX_SIZE) { - return { - id: crypto.randomUUID(), - type: getAttachmentType(file.type), - name: file.name, - file, - contentType: file.type, - status: { - type: "incomplete", - reason: "error" - } - }; + if (file.size > MAX_FILE_SIZE) { + throw new Error( + `File "${file.name}" exceeds the 10 MB size limit (${(file.size / 1024 / 1024).toFixed(1)} MB).` + ); } return { @@ -33,33 +25,40 @@ import type { file, contentType: file.type, status: { - type: "running", - reason: "uploading", - progress: 0 - } + type: "requires-action", + reason: "composer-send", + }, }; }, async send(attachment: PendingAttachment): Promise { - const isImage = attachment.contentType.startsWith("image/"); - - const content: ThreadUserContentPart[] = isImage - ? [{ - type: "image", - image: await fileToBase64(attachment.file) - }] - : [{ - type: "file", - data: URL.createObjectURL(attachment.file), - mimeType: attachment.file.type - }]; - + const isImage = attachment.contentType?.startsWith("image/"); + + let content: ThreadUserContentPart[]; + + try { + content = isImage + ? [{ + type: "image", + image: await fileToBase64(attachment.file), + }] + : [{ + type: "file", + data: URL.createObjectURL(attachment.file), + mimeType: attachment.file.type, + }]; + } catch (err) { + throw new Error( + `Failed to process attachment "${attachment.name}": ${err instanceof Error ? err.message : "unknown error"}` + ); + } + return { ...attachment, content, status: { - type: "complete" - } + type: "complete", + }, }; }, From 4d22430b46140e69c51d808b27e3418d5a853708 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Wed, 18 Feb 2026 18:23:20 +0500 Subject: [PATCH 12/34] add messageInstance for throwing errors in attachments for AI chat component --- .../comps/comps/chatComp/utils/attachmentAdapter.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts index a70afc734..9ff22d436 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts @@ -5,6 +5,7 @@ import type { Attachment, ThreadUserContentPart } from "@assistant-ui/react"; +import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB @@ -13,6 +14,9 @@ import type { async add({ file }): Promise { if (file.size > MAX_FILE_SIZE) { + messageInstance.error( + `File "${file.name}" exceeds the 10 MB size limit (${(file.size / 1024 / 1024).toFixed(1)} MB).` + ); throw new Error( `File "${file.name}" exceeds the 10 MB size limit (${(file.size / 1024 / 1024).toFixed(1)} MB).` ); @@ -48,9 +52,9 @@ import type { mimeType: attachment.file.type, }]; } catch (err) { - throw new Error( - `Failed to process attachment "${attachment.name}": ${err instanceof Error ? err.message : "unknown error"}` - ); + const errorMessage = `Failed to process attachment "${attachment.name}": ${err instanceof Error ? err.message : "unknown error"}`; + messageInstance.error(errorMessage); + throw new Error(errorMessage); } return { From 7fd74ca97b9097f44bae09afb313f8af970264fd Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 19 Feb 2026 23:39:34 +0500 Subject: [PATCH 13/34] remove unnecessary settings from the AI chat component --- .../src/comps/comps/chatComp/chatComp.tsx | 50 +++----------- .../src/comps/comps/chatComp/chatCompTypes.ts | 7 +- .../comps/comps/chatComp/chatPropertyView.tsx | 65 ++++++++++--------- .../comps/comps/chatComp/types/chatTypes.ts | 2 - .../packages/lowcoder/src/i18n/locales/en.ts | 12 ---- 5 files changed, 46 insertions(+), 90 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index b923e3aab..2cfc16d1c 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -6,8 +6,6 @@ import { StringControl } from "comps/controls/codeControl"; import { arrayObjectExposingStateControl, stringExposingStateControl } from "comps/controls/codeStateControl"; import { JSONObject } from "util/jsonTypes"; import { withDefault } from "comps/generators"; -import { BoolControl } from "comps/controls/boolControl"; -import { dropdownControl } from "comps/controls/dropdownControl"; import QuerySelectControl from "comps/controls/querySelectControl"; import { eventHandlerControl, EventConfigType } from "comps/controls/eventHandlerControl"; import { AutoHeightControl } from "comps/controls/autoHeightControl"; @@ -15,7 +13,7 @@ import { ChatContainer } from "./components/ChatContainer"; import { ChatProvider } from "./components/context/ChatContext"; import { ChatPropertyView } from "./chatPropertyView"; import { createChatStorage } from "./utils/storageFactory"; -import { QueryHandler, createMessageHandler } from "./handlers/messageHandlers"; +import { QueryHandler } from "./handlers/messageHandlers"; import { useMemo, useRef, useEffect } from "react"; import { changeChildAction } from "lowcoder-core"; import { ChatMessage } from "./types/chatTypes"; @@ -143,21 +141,13 @@ function generateUniqueTableName(): string { return `chat${Math.floor(1000 + Math.random() * 9000)}`; } -const ModelTypeOptions = [ - { label: trans("chat.handlerTypeQuery"), value: "query" }, - { label: trans("chat.handlerTypeN8N"), value: "n8n" }, -] as const; - export const chatChildrenMap = { - // Storage - // Storage (add the hidden property here) + // Storage (internal, hidden) _internalDbName: withDefault(StringControl, ""), + // Message Handler Configuration - handlerType: dropdownControl(ModelTypeOptions, "query"), - chatQuery: QuerySelectControl, // Only used for "query" type - modelHost: withDefault(StringControl, ""), // Only used for "n8n" type + chatQuery: QuerySelectControl, systemPrompt: withDefault(StringControl, trans("chat.defaultSystemPrompt")), - streaming: BoolControl.DEFAULT_TRUE, // UI Configuration placeholder: withDefault(StringControl, trans("chat.defaultPlaceholder")), @@ -220,36 +210,14 @@ const ChatTmpComp = new UICompBuilder( [] ); - // Create message handler based on type + // Create message handler (Query only) const messageHandler = useMemo(() => { - const handlerType = props.handlerType; - - if (handlerType === "query") { - return new QueryHandler({ - chatQuery: props.chatQuery.value, - dispatch, - streaming: props.streaming, - }); - } else if (handlerType === "n8n") { - return createMessageHandler("n8n", { - modelHost: props.modelHost, - systemPrompt: props.systemPrompt, - streaming: props.streaming - }); - } else { - // Fallback to mock handler - return createMessageHandler("mock", { - chatQuery: props.chatQuery.value, - dispatch, - streaming: props.streaming - }); - } + return new QueryHandler({ + chatQuery: props.chatQuery.value, + dispatch, + }); }, [ - props.handlerType, props.chatQuery, - props.modelHost, - props.systemPrompt, - props.streaming, dispatch, ]); diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts index 3151bff6a..54b75f496 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts @@ -8,12 +8,9 @@ export type ChatCompProps = { // Storage tableName: string; - // Message Handler - handlerType: "query" | "n8n"; - chatQuery: string; // Only used when handlerType === "query" - modelHost: string; // Only used when handlerType === "n8n" + // Message Handler (Query only) + chatQuery: string; systemPrompt: string; - streaming: boolean; // UI placeholder: string; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx index 1e396ebcb..83a3fc1f3 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx @@ -4,6 +4,7 @@ import React, { useMemo } from "react"; import { Section, sectionNames, DocLink } from "lowcoder-design"; import { trans } from "i18n"; import { hiddenPropertyView } from "comps/utils/propertyUtils"; +import { controlItem } from "lowcoder-design"; // ============================================================================ // CLEAN PROPERTY VIEW - FOCUSED ON ESSENTIAL CONFIGURATION @@ -27,38 +28,16 @@ export const ChatPropertyView = React.memo((props: any) => { {/* Message Handler Configuration */}
- {children.handlerType.propertyView({ - label: trans("chat.handlerType"), - tooltip: trans("chat.handlerTypeTooltip"), + {children.chatQuery.propertyView({ + label: trans("chat.chatQuery"), + placeholder: trans("chat.chatQueryPlaceholder"), })} - {/* Conditional Query Selection */} - {children.handlerType.getView() === "query" && ( - children.chatQuery.propertyView({ - label: trans("chat.chatQuery"), - placeholder: trans("chat.chatQueryPlaceholder"), - }) - )} - - {/* Conditional N8N Configuration */} - {children.handlerType.getView() === "n8n" && ( - children.modelHost.propertyView({ - label: trans("chat.modelHost"), - placeholder: trans("chat.modelHostPlaceholder"), - tooltip: trans("chat.modelHostTooltip"), - }) - )} - {children.systemPrompt.propertyView({ label: trans("chat.systemPrompt"), placeholder: trans("chat.systemPromptPlaceholder"), tooltip: trans("chat.systemPromptTooltip"), })} - - {children.streaming.propertyView({ - label: trans("chat.streaming"), - tooltip: trans("chat.streamingTooltip"), - })}
{/* UI Configuration */} @@ -81,11 +60,37 @@ export const ChatPropertyView = React.memo((props: any) => { {/* Database Section */}
- {children.databaseName.propertyView({ - label: trans("chat.databaseName"), - tooltip: trans("chat.databaseNameTooltip"), - readonly: true - })} + {controlItem( + { filterText: trans("chat.databaseName") }, +
+
+ {trans("chat.databaseName")} +
+
+ {children.databaseName.getView() || "Not initialized"} +
+
+ {trans("chat.databaseNameTooltip")} +
+
+ )}
{/* STANDARD EVENT HANDLERS SECTION */} diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index b465eda8b..d24e0ce84 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -63,8 +63,6 @@ export interface ChatMessage { export interface QueryHandlerConfig { chatQuery: string; dispatch: any; - streaming?: boolean; - systemPrompt?: string; } // ============================================================================ diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 9c3accda6..38b43aab0 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -1443,18 +1443,11 @@ export const en = { "chat": { // Property View Labels & Tooltips - "handlerType": "Handler Type", - "handlerTypeTooltip": "How messages are processed", "chatQuery": "Chat Query", "chatQueryPlaceholder": "Select a query to handle messages", - "modelHost": "N8N Webhook URL", - "modelHostPlaceholder": "http://localhost:5678/webhook/...", - "modelHostTooltip": "N8N webhook endpoint for processing messages", "systemPrompt": "System Prompt", "systemPromptPlaceholder": "You are a helpful assistant...", "systemPromptTooltip": "Initial instructions for the AI", - "streaming": "Enable Streaming", - "streamingTooltip": "Stream responses in real-time (when supported)", "databaseName": "Database Name", "databaseNameTooltip": "Auto-generated database name for this chat component (read-only)", @@ -1475,11 +1468,6 @@ export const en = { // Error Messages "errorUnknown": "Sorry, I encountered an error. Please try again.", - - // Handler Types - "handlerTypeQuery": "Query", - "handlerTypeN8N": "N8N Workflow", - // Section Names "messageHandler": "Message Handler", "uiConfiguration": "UI Configuration", From 0c4e8857e065d9f87f264a33c6d5ce6f176ae5a8 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 20 Feb 2026 22:28:14 +0500 Subject: [PATCH 14/34] fix image attachments preview and remove attachments from the bottom panel --- .../src/comps/comps/chatComp/chatComp.tsx | 8 ++-- .../src/comps/comps/chatComp/chatCompTypes.ts | 2 +- .../comps/comps/chatComp/chatPropertyView.tsx | 2 +- .../chatComp/components/ChatContainer.tsx | 7 ++-- .../components/ChatPanelContainer.tsx | 27 ++---------- .../components/assistant-ui/thread.tsx | 42 ++++++++++++------- 6 files changed, 40 insertions(+), 48 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index 2cfc16d1c..39de2e739 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -182,7 +182,7 @@ export const chatChildrenMap = { }; // ============================================================================ -// CLEAN CHATCOMP - USES NEW ARCHITECTURE +// CHATCOMP // ============================================================================ const ChatTmpComp = new UICompBuilder( @@ -222,7 +222,7 @@ const ChatTmpComp = new UICompBuilder( ]); // Handle message updates for exposed variable - // Using Lowcoder pattern: props.currentMessage.onChange() instead of dispatch(changeChildAction(...)) + // Using Lowcoder pattern: props.currentMessage.onChange() const handleMessageUpdate = (message: string) => { props.currentMessage.onChange(message); // Trigger messageSent event @@ -259,7 +259,7 @@ const ChatTmpComp = new UICompBuilder( }; }, []); - // Group all styles into single object for cleaner prop passing + // custom styles const styles = { style: props.style, sidebarStyle: props.sidebarStyle, @@ -300,7 +300,7 @@ const ChatCompWithAutoHeight = class extends ChatTmpComp { }; // ============================================================================ -// EXPORT WITH EXPOSED VARIABLES +// EXPOSED VARIABLES // ============================================================================ export const ChatComp = withExposingConfigs(ChatCompWithAutoHeight, [ diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts index 54b75f496..9bb53a72a 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts @@ -1,7 +1,7 @@ // client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts // ============================================================================ -// CLEAN CHATCOMP TYPES - SIMPLIFIED AND FOCUSED +// CHATCOMP TYPES // ============================================================================ export type ChatCompProps = { diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx index 83a3fc1f3..b12aafd41 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx @@ -7,7 +7,7 @@ import { hiddenPropertyView } from "comps/utils/propertyUtils"; import { controlItem } from "lowcoder-design"; // ============================================================================ -// CLEAN PROPERTY VIEW - FOCUSED ON ESSENTIAL CONFIGURATION +// PROPERTY VIEW // ============================================================================ export const ChatPropertyView = React.memo((props: any) => { diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx index af8028b4d..689e0dc28 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx @@ -24,7 +24,7 @@ import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; import { StyledChatContainer } from "./ChatContainerStyles"; // ============================================================================ -// CHAT CONTAINER - USES CONTEXT FROM CHATPROVIDER +// CHAT CONTAINER // ============================================================================ const generateId = () => Math.random().toString(36).substr(2, 9); @@ -33,8 +33,7 @@ function ChatContainerView(props: ChatCoreProps) { const { state, actions } = useChatContext(); const [isRunning, setIsRunning] = useState(false); - // Store callback props in refs so useEffects don't re-fire - // when Lowcoder's builder creates new function references on each render + // callback props in refs so useEffects don't re-fire const onConversationUpdateRef = useRef(props.onConversationUpdate); onConversationUpdateRef.current = props.onConversationUpdate; @@ -247,7 +246,7 @@ function ChatContainerView(props: ChatCoreProps) { } // ============================================================================ -// EXPORT - SIMPLIFIED (PROVIDERS MOVED UP ONE LEVEL) +// EXPORT // ============================================================================ export const ChatContainer = ChatContainerView; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx index 612afc32e..bcf1514af 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx @@ -7,7 +7,6 @@ import { AppendMessage, AssistantRuntimeProvider, ExternalStoreThreadListAdapter, - CompleteAttachment, TextContentPart, ThreadUserContentPart } from "@assistant-ui/react"; @@ -22,7 +21,6 @@ import { import { MessageHandler, ChatMessage } from "../types/chatTypes"; import styled from "styled-components"; import { trans } from "i18n"; -import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; import { TooltipProvider } from "@radix-ui/react-tooltip"; import "@assistant-ui/styles/index.css"; @@ -89,20 +87,11 @@ function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit { const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; - if (message.attachments && message.attachments.length > 0) { - for (const attachment of message.attachments) { - if (attachment.content) { - content.push(...attachment.content); - } - } - } - return { role: message.role, content, id: message.id, createdAt: new Date(message.timestamp), - ...(message.attachments && message.attachments.length > 0 && { attachments: message.attachments }), }; }; @@ -112,11 +101,8 @@ function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit att.status.type === "complete" - ); - if (!text && !completeAttachments.length) { + if (!text) { throw new Error("Cannot send an empty message"); } @@ -125,7 +111,6 @@ function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit att.status.type === "complete" - ); - if (!text && !completeAttachments.length) { + if (!text) { throw new Error("Cannot send an empty message"); } @@ -175,7 +157,6 @@ function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit - +
); diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx index 616acc087..a45e5fe14 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx @@ -5,7 +5,7 @@ import { MessagePrimitive, ThreadPrimitive, } from "@assistant-ui/react"; - import type { FC } from "react"; + import { useMemo, type FC } from "react"; import { trans } from "i18n"; import { ArrowDownIcon, @@ -14,7 +14,6 @@ import { ChevronRightIcon, CopyIcon, PencilIcon, - RefreshCwIcon, SendHorizontalIcon, } from "lucide-react"; import { cn } from "../../utils/cn"; @@ -54,9 +53,20 @@ import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } fr interface ThreadProps { placeholder?: string; + showAttachments?: boolean; } - export const Thread: FC = ({ placeholder = trans("chat.composerPlaceholder") }) => { + export const Thread: FC = ({ + placeholder = trans("chat.composerPlaceholder"), + showAttachments = true + }) => { + // Stable component reference so React doesn't unmount/remount on every render + const UserMessageComponent = useMemo(() => { + const Wrapper: FC = () => ; + Wrapper.displayName = "UserMessage"; + return Wrapper; + }, [showAttachments]); + return ( - + @@ -148,11 +158,18 @@ import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } fr ); }; - const Composer: FC<{ placeholder?: string }> = ({ placeholder = trans("chat.composerPlaceholder") }) => { + const Composer: FC<{ placeholder?: string; showAttachments?: boolean }> = ({ + placeholder = trans("chat.composerPlaceholder"), + showAttachments = true + }) => { return ( - - + {showAttachments && ( + <> + + + + )} { + const UserMessage: FC<{ showAttachments?: boolean }> = ({ showAttachments = true }) => { return ( - + {showAttachments && }
@@ -273,11 +290,6 @@ import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } fr - - - - - ); }; From 57fd46896453c05bd8c03c873b84fe46a70ceb9e Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Wed, 25 Feb 2026 21:33:05 +0500 Subject: [PATCH 15/34] add chatCompv2 + new chatData store --- .../comps/chatBoxComponentv2/chatDataStore.ts | 322 ++++++++++++++++++ client/packages/lowcoder/src/comps/index.tsx | 15 + .../lowcoder/src/comps/uiCompRegistry.ts | 1 + .../src/pages/editor/editorConstants.tsx | 1 + 4 files changed, 339 insertions(+) create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts new file mode 100644 index 000000000..f210782ce --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts @@ -0,0 +1,322 @@ +import alasql from "alasql"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface ChatMessage { + id: string; + roomId: string; + authorId: string; + authorName: string; + text: string; + timestamp: number; +} + +export interface ChatRoom { + id: string; + name: string; + description: string; + type: "public" | "private"; + creatorId: string; + createdAt: number; + updatedAt: number; +} + +export interface RoomMember { + roomId: string; + userId: string; + userName: string; + joinedAt: number; +} + +export type ChatStoreListener = () => void; + +// ─── Store ─────────────────────────────────────────────────────────────────── + +const CROSS_TAB_EVENT = "chatbox-v2-update"; + +/** + * Thin wrapper around ALASql providing chat persistence. + * + * Design goals: + * - One class, zero abstraction layers. + * - Schema actually matches the data we need (rooms store type, description, + * etc.; messages store authorId/authorName; membership in its own table). + * - Synchronous-feel API (all methods are async because ALASql is, but there + * is no extra indirection). + * - Change listeners so React can subscribe without polling. + */ +export class ChatDataStore { + private dbName: string; + private ready = false; + private listeners = new Set(); + + constructor(applicationId: string) { + this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`; + } + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + async init(): Promise { + if (this.ready) return; + alasql.options.autocommit = true; + + await alasql.promise(`CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${this.dbName}`); + await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${this.dbName}`); + await alasql.promise(`USE ${this.dbName}`); + + await alasql.promise(` + CREATE TABLE IF NOT EXISTS rooms ( + id STRING PRIMARY KEY, + name STRING, + description STRING, + type STRING, + creatorId STRING, + createdAt NUMBER, + updatedAt NUMBER + ) + `); + await alasql.promise(` + CREATE TABLE IF NOT EXISTS messages ( + id STRING PRIMARY KEY, + roomId STRING, + authorId STRING, + authorName STRING, + text STRING, + timestamp NUMBER + ) + `); + await alasql.promise(` + CREATE TABLE IF NOT EXISTS members ( + roomId STRING, + userId STRING, + userName STRING, + joinedAt NUMBER + ) + `); + this.ready = true; + + if (typeof window !== "undefined") { + window.addEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate); + } + } + + destroy(): void { + if (typeof window !== "undefined") { + window.removeEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate); + } + this.listeners.clear(); + } + + // ── Subscriptions (React-friendly) ─────────────────────────────────────── + + subscribe(listener: ChatStoreListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + this.listeners.forEach((fn) => fn()); + if (typeof window !== "undefined") { + try { + window.dispatchEvent(new CustomEvent(CROSS_TAB_EVENT)); + } catch { + // CustomEvent not supported in test environments + } + } + } + + private onCrossTabUpdate = () => { + this.listeners.forEach((fn) => fn()); + }; + + // ── Rooms ──────────────────────────────────────────────────────────────── + + async createRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + description = "", + ): Promise { + this.assertReady(); + const id = this.uid(); + const now = Date.now(); + await alasql.promise( + `INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?)`, + [id, name, description, type, creatorId, now, now], + ); + await alasql.promise( + `INSERT INTO members VALUES (?, ?, ?, ?)`, + [id, creatorId, creatorName, now], + ); + this.notify(); + return { id, name, description, type, creatorId, createdAt: now, updatedAt: now }; + } + + async getRoom(roomId: string): Promise { + this.assertReady(); + const rows = (await alasql.promise( + `SELECT * FROM rooms WHERE id = ?`, + [roomId], + )) as ChatRoom[]; + return rows.length > 0 ? rows[0] : null; + } + + async getRoomByName(name: string): Promise { + this.assertReady(); + const rows = (await alasql.promise( + `SELECT * FROM rooms WHERE name = ?`, + [name], + )) as ChatRoom[]; + return rows.length > 0 ? rows[0] : null; + } + + async getAllRooms(): Promise { + this.assertReady(); + return (await alasql.promise( + `SELECT * FROM rooms ORDER BY updatedAt DESC`, + )) as ChatRoom[]; + } + + async getUserRooms(userId: string): Promise { + this.assertReady(); + return (await alasql.promise( + `SELECT r.* FROM rooms r JOIN members m ON r.id = m.roomId WHERE m.userId = ? ORDER BY r.updatedAt DESC`, + [userId], + )) as ChatRoom[]; + } + + async getSearchableRooms(userId: string, query: string): Promise { + this.assertReady(); + const q = `%${query}%`; + return (await alasql.promise( + `SELECT DISTINCT r.* FROM rooms r + WHERE r.type = 'public' + AND r.id NOT IN (SELECT roomId FROM members WHERE userId = ?) + AND (r.name LIKE ? OR r.description LIKE ?) + ORDER BY r.updatedAt DESC`, + [userId, q, q], + )) as ChatRoom[]; + } + + // ── Membership ─────────────────────────────────────────────────────────── + + async joinRoom(roomId: string, userId: string, userName: string): Promise { + this.assertReady(); + const existing = (await alasql.promise( + `SELECT * FROM members WHERE roomId = ? AND userId = ?`, + [roomId, userId], + )) as RoomMember[]; + if (existing.length > 0) return true; // already a member + await alasql.promise( + `INSERT INTO members VALUES (?, ?, ?, ?)`, + [roomId, userId, userName, Date.now()], + ); + this.notify(); + return true; + } + + async leaveRoom(roomId: string, userId: string): Promise { + this.assertReady(); + await alasql.promise( + `DELETE FROM members WHERE roomId = ? AND userId = ?`, + [roomId, userId], + ); + this.notify(); + return true; + } + + async getRoomMembers(roomId: string): Promise { + this.assertReady(); + return (await alasql.promise( + `SELECT * FROM members WHERE roomId = ? ORDER BY joinedAt ASC`, + [roomId], + )) as RoomMember[]; + } + + async isMember(roomId: string, userId: string): Promise { + this.assertReady(); + const rows = (await alasql.promise( + `SELECT * FROM members WHERE roomId = ? AND userId = ?`, + [roomId, userId], + )) as RoomMember[]; + return rows.length > 0; + } + + // ── Messages ───────────────────────────────────────────────────────────── + + async sendMessage( + roomId: string, + authorId: string, + authorName: string, + text: string, + ): Promise { + this.assertReady(); + const msg: ChatMessage = { + id: this.uid(), + roomId, + authorId, + authorName, + text, + timestamp: Date.now(), + }; + await alasql.promise( + `INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)`, + [msg.id, msg.roomId, msg.authorId, msg.authorName, msg.text, msg.timestamp], + ); + await alasql.promise( + `UPDATE rooms SET updatedAt = ? WHERE id = ?`, + [msg.timestamp, roomId], + ); + this.notify(); + return msg; + } + + async getMessages(roomId: string, limit = 100): Promise { + this.assertReady(); + const rows = (await alasql.promise( + `SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC`, + [roomId], + )) as ChatMessage[]; + return rows.slice(-limit); + } + + // ── Or-create helpers (for initial room setup) ─────────────────────────── + + async ensureRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + ): Promise { + let room = await this.getRoomByName(name); + if (!room) { + room = await this.createRoom(name, type, creatorId, creatorName); + } + const member = await this.isMember(room.id, creatorId); + if (!member) { + await this.joinRoom(room.id, creatorId, creatorName); + } + return room; + } + + // ── Internals ──────────────────────────────────────────────────────────── + + private assertReady(): void { + if (!this.ready) throw new Error("ChatDataStore not initialized. Call init() first."); + } + + private uid(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + } +} + +// Global cache keyed by applicationId so multiple components share one store. +const storeCache = new Map(); + +export function getChatStore(applicationId: string): ChatDataStore { + if (!storeCache.has(applicationId)) { + storeCache.set(applicationId, new ChatDataStore(applicationId)); + } + return storeCache.get(applicationId)!; +} diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index be34b1670..fb30d587b 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -196,6 +196,7 @@ import { ContainerComp as FloatTextContainerComp } from "./comps/containerComp/t import { ChatComp } from "./comps/chatComp"; import { ChatBoxComp } from "./comps/chatBoxComponent"; import { ChatControllerComp } from "./comps/chatBoxComponent/chatControllerComp"; +import { ChatBoxV2Comp } from "./comps/chatBoxComponentv2"; type Registry = { [key in UICompType]?: UICompManifest; @@ -973,6 +974,20 @@ export var uiCompMap: Registry = { isContainer: true, }, + chatBoxV2: { + name: "Chat Box V2", + enName: "Chat Box V2", + description: "Chat Box with rooms, messaging, and local persistence", + categories: ["collaboration"], + icon: CommentCompIcon, + keywords: "chatbox,chat,conversation,rooms,messaging,v2", + comp: ChatBoxV2Comp, + layoutInfo: { + w: 12, + h: 24, + }, + }, + // Forms form: { diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index 1de611df8..4660fc671 100644 --- a/client/packages/lowcoder/src/comps/uiCompRegistry.ts +++ b/client/packages/lowcoder/src/comps/uiCompRegistry.ts @@ -145,6 +145,7 @@ export type UICompType = | "chat" //Added By Kamal Qureshi | "chatBox" //Added By Kamal Qureshi | "chatController" + | "chatBoxV2" | "autocomplete" //Added By Mousheng | "colorPicker" //Added By Mousheng | "floatingButton" //Added By Mousheng diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index a558d8b8d..87ae7c984 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -309,4 +309,5 @@ export const CompStateIcon: { chat: , chatBox: , chatController: , + chatBoxV2: , } as const; From 18abefbca87adf0a80f9627912f3b1db981a43cb Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 27 Feb 2026 00:51:16 +0500 Subject: [PATCH 16/34] setup basic data structure for chatv2 --- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 653 ++++++++++++++++++ .../comps/comps/chatBoxComponentv2/index.tsx | 1 + .../comps/chatBoxComponentv2/useChatStore.ts | 247 +++++++ 3 files changed, 901 insertions(+) create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/index.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx new file mode 100644 index 000000000..e4245469c --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -0,0 +1,653 @@ +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import styled, { css } from "styled-components"; +import { Button, Input, Modal, Form, Radio, Space, Tooltip, Popconfirm } from "antd"; +import { + PlusOutlined, + SearchOutlined, + GlobalOutlined, + LockOutlined, + UserOutlined, + LogoutOutlined, + SendOutlined, +} from "@ant-design/icons"; + +import { Section, sectionNames } from "lowcoder-design"; +import { UICompBuilder, withDefault } from "../../generators"; +import { NameConfig, NameConfigHidden, withExposingConfigs } from "../../generators/withExposing"; +import { withMethodExposing } from "../../generators/withMethodExposing"; +import { stringExposingStateControl } from "comps/controls/codeStateControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { StringControl } from "comps/controls/codeControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { AutoHeightControl } from "comps/controls/autoHeightControl"; +import { eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { styleControl } from "comps/controls/styleControl"; +import { AnimationStyle, TextStyle, TextStyleType, AnimationStyleType } from "comps/controls/styleControlConstants"; +import { hiddenPropertyView } from "comps/utils/propertyUtils"; +import { EditorContext } from "comps/editorState"; +import { trans } from "i18n"; + +import { useChatStore } from "./useChatStore"; +import type { ChatMessage, ChatRoom, RoomMember } from "./chatDataStore"; + +// ─── Event definitions ────────────────────────────────────────────────────── + +const ChatEvents = [ + { label: trans("chatBox.messageSent"), value: "messageSent", description: trans("chatBox.messageSentDesc") }, + { label: trans("chatBox.messageReceived"), value: "messageReceived", description: trans("chatBox.messageReceivedDesc") }, + { label: trans("chatBox.roomJoined"), value: "roomJoined", description: trans("chatBox.roomJoinedDesc") }, + { label: trans("chatBox.roomLeft"), value: "roomLeft", description: trans("chatBox.roomLeftDesc") }, +] as const; + +// ─── Children map (component properties) ──────────────────────────────────── + +const childrenMap = { + chatName: stringExposingStateControl("chatName", "Chat Room"), + userId: stringExposingStateControl("userId", "user_1"), + userName: stringExposingStateControl("userName", "User"), + applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), + defaultRoom: withDefault(StringControl, "general"), + + allowRoomCreation: withDefault(BoolControl, true), + allowRoomSearch: withDefault(BoolControl, true), + showRoomPanel: withDefault(BoolControl, true), + roomPanelWidth: withDefault(StringControl, "220px"), + + autoHeight: AutoHeightControl, + onEvent: eventHandlerControl(ChatEvents), + style: styleControl(TextStyle, "style"), + animationStyle: styleControl(AnimationStyle, "animationStyle"), +}; + +// ─── Styled components ────────────────────────────────────────────────────── + +const Wrapper = styled.div<{ $style: TextStyleType; $anim: AnimationStyleType }>` + height: 100%; + display: flex; + overflow: hidden; + border-radius: ${(p) => p.$style.radius || "8px"}; + border: ${(p) => p.$style.borderWidth || "1px"} solid ${(p) => p.$style.border || "#e0e0e0"}; + background: ${(p) => p.$style.background || "#fff"}; + font-family: ${(p) => p.$style.fontFamily || "inherit"}; + ${(p) => p.$anim} +`; + +const RoomPanel = styled.div<{ $width: string }>` + width: ${(p) => p.$width}; + min-width: 160px; + border-right: 1px solid #eee; + display: flex; + flex-direction: column; + background: #fafbfc; +`; + +const RoomPanelHeader = styled.div` + padding: 12px; + font-weight: 600; + font-size: 13px; + color: #555; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; +`; + +const RoomList = styled.div` + flex: 1; + overflow-y: auto; + padding: 8px; +`; + +const RoomItemStyled = styled.div<{ $active: boolean }>` + padding: 8px 10px; + margin-bottom: 4px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 6px; + background: ${(p) => (p.$active ? "#1890ff" : "#fff")}; + color: ${(p) => (p.$active ? "#fff" : "#333")}; + border: 1px solid ${(p) => (p.$active ? "#1890ff" : "#f0f0f0")}; + + &:hover { + background: ${(p) => (p.$active ? "#1890ff" : "#f5f5f5")}; + } +`; + +const ChatPanel = styled.div` + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +`; + +const ChatHeaderBar = styled.div` + padding: 12px 16px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const MessagesArea = styled.div` + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +const Bubble = styled.div<{ $own: boolean }>` + max-width: 70%; + padding: 10px 14px; + border-radius: 16px; + align-self: ${(p) => (p.$own ? "flex-end" : "flex-start")}; + background: ${(p) => (p.$own ? "#1890ff" : "#f0f0f0")}; + color: ${(p) => (p.$own ? "#fff" : "#333")}; + font-size: 14px; + word-break: break-word; +`; + +const BubbleMeta = styled.div<{ $own: boolean }>` + font-size: 11px; + opacity: 0.7; + margin-bottom: 2px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +const BubbleTime = styled.div<{ $own: boolean }>` + font-size: 10px; + opacity: 0.6; + margin-top: 4px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +const InputBar = styled.div` + padding: 12px 16px; + border-top: 1px solid #eee; + display: flex; + gap: 8px; + align-items: flex-end; +`; + +const StyledTextArea = styled.textarea` + flex: 1; + padding: 8px 14px; + border: 1px solid #d9d9d9; + border-radius: 18px; + resize: none; + min-height: 36px; + max-height: 96px; + font-size: 14px; + outline: none; + font-family: inherit; + line-height: 1.4; + &:focus { + border-color: #1890ff; + } +`; + +const EmptyChat = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #999; + gap: 4px; +`; + +const SearchResultBadge = styled.span` + font-size: 10px; + background: #e6f7ff; + color: #1890ff; + padding: 1px 6px; + border-radius: 8px; + font-weight: 500; + margin-left: auto; +`; + +// ─── View component ───────────────────────────────────────────────────────── + +const ChatBoxView = React.memo((props: any) => { + const { + chatName, + userId, + userName, + applicationId, + defaultRoom, + allowRoomCreation, + allowRoomSearch, + showRoomPanel, + roomPanelWidth, + style, + animationStyle, + onEvent, + } = props; + + const chat = useChatStore({ + applicationId: applicationId.value || "lowcoder_app", + defaultRoom: defaultRoom || "general", + userId: userId.value || "user_1", + userName: userName.value || "User", + }); + + const [draft, setDraft] = useState(""); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearchMode, setIsSearchMode] = useState(false); + const [createForm] = Form.useForm(); + const messagesEndRef = useRef(null); + + // Auto-scroll on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [chat.messages]); + + // ── Handlers ─────────────────────────────────────────────────────────── + + const handleSend = useCallback(async () => { + if (!draft.trim()) return; + const ok = await chat.sendMessage(draft); + if (ok) { + setDraft(""); + onEvent("messageSent"); + } + }, [draft, chat.sendMessage, onEvent]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + const handleCreateRoom = useCallback( + async (values: { roomName: string; roomType: "public" | "private"; description?: string }) => { + const room = await chat.createRoom(values.roomName.trim(), values.roomType, values.description); + if (room) { + createForm.resetFields(); + setCreateModalOpen(false); + onEvent("roomJoined"); + } + }, + [chat.createRoom, createForm, onEvent], + ); + + const handleJoinRoom = useCallback( + async (roomId: string) => { + const ok = await chat.joinRoom(roomId); + if (ok) { + setSearchQuery(""); + setSearchResults([]); + setIsSearchMode(false); + onEvent("roomJoined"); + } + }, + [chat.joinRoom, onEvent], + ); + + const handleLeaveRoom = useCallback( + async (roomId: string) => { + const ok = await chat.leaveRoom(roomId); + if (ok) onEvent("roomLeft"); + }, + [chat.leaveRoom, onEvent], + ); + + const handleSearch = useCallback( + async (q: string) => { + setSearchQuery(q); + if (!q.trim()) { + setIsSearchMode(false); + setSearchResults([]); + return; + } + setIsSearchMode(true); + const results = await chat.searchRooms(q); + setSearchResults(results); + }, + [chat.searchRooms], + ); + + // ── Render ───────────────────────────────────────────────────────────── + + const roomListItems = isSearchMode ? searchResults : chat.userRooms; + + return ( + + {/* Room Panel */} + {showRoomPanel && ( + + + Rooms + {allowRoomCreation && ( + + +
+ )} + + + {roomListItems.length === 0 && !isSearchMode && chat.ready && ( +
+ No rooms yet. Create or search for one. +
+ )} + + {roomListItems.map((room) => { + const isActive = chat.currentRoom?.id === room.id; + const isSearch = isSearchMode; + + return ( + { + if (isSearch) { + handleJoinRoom(room.id); + } else if (!isActive) { + chat.switchRoom(room.id); + } + }} + title={isSearch ? `Join "${room.name}"` : room.name} + > + {room.type === "public" ? ( + + ) : ( + + )} + + {room.name} + + {isSearch && Join} + {isActive && !isSearch && ( + { + e?.stopPropagation(); + handleLeaveRoom(room.id); + }} + onCancel={(e) => e?.stopPropagation()} + okText="Leave" + cancelText="Cancel" + okButtonProps={{ danger: true }} + > + e.stopPropagation()} + style={{ fontSize: 12, opacity: 0.7 }} + /> + + )} + + ); + })} +
+ + )} + + {/* Chat Panel */} + + +
+
{chatName.value}
+
+ {chat.currentRoom?.name || "No room selected"} + {chat.currentRoomMembers.length > 0 && ( + + + {chat.currentRoomMembers.length} + + )} +
+
+
+ {chat.ready ? "Connected" : chat.error || "Connecting..."} +
+
+ + + {chat.messages.length === 0 ? ( + +
💬
+
No messages yet
+
+ {chat.ready ? "Start the conversation!" : "Connecting..."} +
+
+ ) : ( + chat.messages.map((msg: ChatMessage) => { + const isOwn = msg.authorId === userId.value; + return ( +
+ {msg.authorName} + {msg.text} + + {new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + +
+ ); + }) + )} +
+ + + + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={chat.ready ? "Type a message..." : "Connecting..."} + disabled={!chat.ready || !chat.currentRoom} + rows={1} + /> + + + + + + + + ); +}); + +ChatBoxView.displayName = "ChatBoxV2View"; + +// ─── Property panel ───────────────────────────────────────────────────────── + +const ChatBoxPropertyView = React.memo((props: { children: any }) => { + const { children } = props; + const editorMode = useContext(EditorContext).editorModeStatus; + + return ( + <> +
+ {children.chatName.propertyView({ label: "Chat Name", tooltip: "Display name for the chat header" })} + {children.userId.propertyView({ label: "User ID", tooltip: "Current user's unique identifier" })} + {children.userName.propertyView({ label: "User Name", tooltip: "Current user's display name" })} + {children.applicationId.propertyView({ label: "Application ID", tooltip: "Scopes rooms to this application" })} + {children.defaultRoom.propertyView({ label: "Default Room", tooltip: "Room to join on load" })} +
+ +
+ {children.allowRoomCreation.propertyView({ label: "Allow Room Creation" })} + {children.allowRoomSearch.propertyView({ label: "Allow Room Search" })} + {children.showRoomPanel.propertyView({ label: "Show Room Panel" })} + {children.roomPanelWidth.propertyView({ label: "Panel Width", tooltip: "e.g. 220px or 25%" })} +
+ + {["logic", "both"].includes(editorMode) && ( +
+ {hiddenPropertyView(children)} + {children.onEvent.getPropertyView()} +
+ )} + + {["layout", "both"].includes(editorMode) && ( + <> +
+ {children.autoHeight.getPropertyView()} +
+
+ {children.style.getPropertyView()} +
+
+ {children.animationStyle.getPropertyView()} +
+ + )} + + ); +}); + +ChatBoxPropertyView.displayName = "ChatBoxV2PropertyView"; + +// ─── Build component ──────────────────────────────────────────────────────── + +let ChatBoxV2Tmp = (function () { + return new UICompBuilder(childrenMap, (props) => ) + .setPropertyViewFn((children) => ) + .build(); +})(); + +ChatBoxV2Tmp = class extends ChatBoxV2Tmp { + override autoHeight(): boolean { + return this.children.autoHeight.getView(); + } +}; + +// ─── Methods ──────────────────────────────────────────────────────────────── + +ChatBoxV2Tmp = withMethodExposing(ChatBoxV2Tmp, [ + { + method: { + name: "setUser", + description: "Update the current chat user", + params: [ + { name: "userId", type: "string" }, + { name: "userName", type: "string" }, + ], + }, + execute: (comp: any, values: any[]) => { + if (values[0]) comp.children.userId.getView().onChange(values[0]); + if (values[1]) comp.children.userName.getView().onChange(values[1]); + }, + }, +]); + +// ─── Exposing configs ─────────────────────────────────────────────────────── + +export const ChatBoxV2Comp = withExposingConfigs(ChatBoxV2Tmp, [ + new NameConfig("chatName", "Chat display name"), + new NameConfig("userId", "Current user ID"), + new NameConfig("userName", "Current user name"), + new NameConfig("applicationId", "Application scope"), + NameConfigHidden, +]); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/index.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/index.tsx new file mode 100644 index 000000000..68429247a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/index.tsx @@ -0,0 +1 @@ +export { ChatBoxV2Comp } from "./chatBoxComp"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts new file mode 100644 index 000000000..ac2feaad8 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -0,0 +1,247 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { ChatDataStore, ChatMessage, ChatRoom, RoomMember, getChatStore } from "./chatDataStore"; + +export interface UseChatStoreConfig { + applicationId: string; + defaultRoom: string; + userId: string; + userName: string; +} + +export interface UseChatStoreReturn { + ready: boolean; + error: string | null; + + currentRoom: ChatRoom | null; + messages: ChatMessage[]; + userRooms: ChatRoom[]; + currentRoomMembers: RoomMember[]; + + sendMessage: (text: string) => Promise; + switchRoom: (roomId: string) => Promise; + createRoom: (name: string, type: "public" | "private", description?: string) => Promise; + joinRoom: (roomId: string) => Promise; + leaveRoom: (roomId: string) => Promise; + searchRooms: (query: string) => Promise; +} + +export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { + const { applicationId, defaultRoom, userId, userName } = config; + + const storeRef = useRef(null); + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + + const [currentRoom, setCurrentRoom] = useState(null); + const [messages, setMessages] = useState([]); + const [userRooms, setUserRooms] = useState([]); + const [currentRoomMembers, setCurrentRoomMembers] = useState([]); + + // Track the "active room id" in a ref so callbacks always see the latest value. + const activeRoomIdRef = useRef(null); + + // ── Refresh helpers ──────────────────────────────────────────────────── + + const refreshRooms = useCallback(async () => { + const store = storeRef.current; + if (!store || !userId) return; + try { + const rooms = await store.getUserRooms(userId); + setUserRooms(rooms); + } catch { + // non-fatal + } + }, [userId]); + + const refreshMessages = useCallback(async () => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + try { + const msgs = await store.getMessages(roomId); + setMessages(msgs); + } catch { + // non-fatal + } + }, []); + + const refreshMembers = useCallback(async () => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + try { + const members = await store.getRoomMembers(roomId); + setCurrentRoomMembers(members); + } catch { + // non-fatal + } + }, []); + + const refreshAll = useCallback(async () => { + await Promise.all([refreshRooms(), refreshMessages(), refreshMembers()]); + }, [refreshRooms, refreshMessages, refreshMembers]); + + // ── Initialization ───────────────────────────────────────────────────── + + useEffect(() => { + if (!applicationId || !userId || !userName) return; + + let cancelled = false; + const store = getChatStore(applicationId); + storeRef.current = store; + + (async () => { + try { + await store.init(); + if (cancelled) return; + + // Ensure the default room exists and user is a member. + const room = await store.ensureRoom(defaultRoom, "public", userId, userName); + if (cancelled) return; + + activeRoomIdRef.current = room.id; + setCurrentRoom(room); + + const [msgs, rooms, members] = await Promise.all([ + store.getMessages(room.id), + store.getUserRooms(userId), + store.getRoomMembers(room.id), + ]); + if (cancelled) return; + + setMessages(msgs); + setUserRooms(rooms); + setCurrentRoomMembers(members); + setReady(true); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Failed to initialize chat store"); + } + })(); + + const unsub = store.subscribe(() => { + if (!cancelled) refreshAll(); + }); + + return () => { + cancelled = true; + unsub(); + }; + }, [applicationId, userId, userName, defaultRoom, refreshAll]); + + // ── Actions ──────────────────────────────────────────────────────────── + + const sendMessage = useCallback( + async (text: string): Promise => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId || !text.trim()) return false; + try { + await store.sendMessage(roomId, userId, userName, text.trim()); + return true; + } catch { + return false; + } + }, + [userId, userName], + ); + + const switchRoom = useCallback( + async (roomId: string) => { + const store = storeRef.current; + if (!store) return; + const room = await store.getRoom(roomId); + if (!room) return; + activeRoomIdRef.current = room.id; + setCurrentRoom(room); + const [msgs, members] = await Promise.all([ + store.getMessages(room.id), + store.getRoomMembers(room.id), + ]); + setMessages(msgs); + setCurrentRoomMembers(members); + }, + [], + ); + + const createRoom = useCallback( + async (name: string, type: "public" | "private", description?: string): Promise => { + const store = storeRef.current; + if (!store) return null; + try { + const room = await store.createRoom(name, type, userId, userName, description); + return room; + } catch { + return null; + } + }, + [userId, userName], + ); + + const joinRoom = useCallback( + async (roomId: string): Promise => { + const store = storeRef.current; + if (!store) return false; + try { + const ok = await store.joinRoom(roomId, userId, userName); + if (ok) await switchRoom(roomId); + return ok; + } catch { + return false; + } + }, + [userId, userName, switchRoom], + ); + + const leaveRoom = useCallback( + async (roomId: string): Promise => { + const store = storeRef.current; + if (!store) return false; + try { + const ok = await store.leaveRoom(roomId, userId); + if (ok && activeRoomIdRef.current === roomId) { + const rooms = await store.getUserRooms(userId); + if (rooms.length > 0) { + await switchRoom(rooms[0].id); + } else { + activeRoomIdRef.current = null; + setCurrentRoom(null); + setMessages([]); + setCurrentRoomMembers([]); + } + } + return ok; + } catch { + return false; + } + }, + [userId, switchRoom], + ); + + const searchRooms = useCallback( + async (query: string): Promise => { + const store = storeRef.current; + if (!store || !query.trim()) return []; + try { + return await store.getSearchableRooms(userId, query.trim()); + } catch { + return []; + } + }, + [userId], + ); + + return { + ready, + error, + currentRoom, + messages, + userRooms, + currentRoomMembers, + sendMessage, + switchRoom, + createRoom, + joinRoom, + leaveRoom, + searchRooms, + }; +} From d8a84230f4aaeab9bf9f8241f1eecc496af22392 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 27 Feb 2026 22:57:37 +0500 Subject: [PATCH 17/34] add yjs support --- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 27 +- .../comps/chatBoxComponentv2/chatDataStore.ts | 619 +++++++++++++----- .../comps/chatBoxComponentv2/useChatStore.ts | 68 +- 3 files changed, 522 insertions(+), 192 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index e4245469c..28fc169c4 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -28,7 +28,7 @@ import { EditorContext } from "comps/editorState"; import { trans } from "i18n"; import { useChatStore } from "./useChatStore"; -import type { ChatMessage, ChatRoom, RoomMember } from "./chatDataStore"; +import type { ChatMessage, ChatRoom, RoomMember, SyncMode } from "./chatDataStore"; // ─── Event definitions ────────────────────────────────────────────────────── @@ -48,6 +48,16 @@ const childrenMap = { applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), defaultRoom: withDefault(StringControl, "general"), + mode: dropdownControl( + [ + { label: "Local (Browser Storage)", value: "local" }, + { label: "Collaborative (WebSocket)", value: "collaborative" }, + { label: "Hybrid (Local + WebSocket)", value: "hybrid" }, + ], + "local", + ), + wsUrl: withDefault(StringControl, "ws://localhost:3005"), + allowRoomCreation: withDefault(BoolControl, true), allowRoomSearch: withDefault(BoolControl, true), showRoomPanel: withDefault(BoolControl, true), @@ -220,6 +230,8 @@ const ChatBoxView = React.memo((props: any) => { userName, applicationId, defaultRoom, + mode, + wsUrl, allowRoomCreation, allowRoomSearch, showRoomPanel, @@ -234,6 +246,8 @@ const ChatBoxView = React.memo((props: any) => { defaultRoom: defaultRoom || "general", userId: userId.value || "user_1", userName: userName.value || "User", + mode: (mode as SyncMode) || "local", + wsUrl: wsUrl || "ws://localhost:3005", }); const [draft, setDraft] = useState(""); @@ -454,7 +468,7 @@ const ChatBoxView = React.memo((props: any) => {
- {chat.ready ? "Connected" : chat.error || "Connecting..."} + {chat.ready ? chat.connectionLabel : chat.error || "Connecting..."}
@@ -574,6 +588,15 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => { {children.userName.propertyView({ label: "User Name", tooltip: "Current user's display name" })} {children.applicationId.propertyView({ label: "Application ID", tooltip: "Scopes rooms to this application" })} {children.defaultRoom.propertyView({ label: "Default Room", tooltip: "Room to join on load" })} + {children.mode.propertyView({ + label: "Sync Mode", + tooltip: "Local: browser-only storage. Collaborative: real-time via WebSocket. Hybrid: both with offline fallback.", + })} + {children.mode.getView() !== "local" && + children.wsUrl.propertyView({ + label: "WebSocket URL", + tooltip: "Yjs WebSocket server URL (e.g. ws://localhost:3005)", + })}
diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts index f210782ce..5b1b13a7c 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts @@ -1,6 +1,6 @@ import alasql from "alasql"; -// ─── Types ─────────────────────────────────────────────────────────────────── +// ─── Shared types ──────────────────────────────────────────────────────────── export interface ChatMessage { id: string; @@ -30,22 +30,41 @@ export interface RoomMember { export type ChatStoreListener = () => void; -// ─── Store ─────────────────────────────────────────────────────────────────── - -const CROSS_TAB_EVENT = "chatbox-v2-update"; +export type SyncMode = "local" | "collaborative" | "hybrid"; /** - * Thin wrapper around ALASql providing chat persistence. - * - * Design goals: - * - One class, zero abstraction layers. - * - Schema actually matches the data we need (rooms store type, description, - * etc.; messages store authorId/authorName; membership in its own table). - * - Synchronous-feel API (all methods are async because ALASql is, but there - * is no extra indirection). - * - Change listeners so React can subscribe without polling. + * Common interface that both ALASql (local) and Yjs (collaborative) stores + * implement. The hook delegates to whichever is active. */ -export class ChatDataStore { +export interface IChatStore { + init(): Promise; + destroy(): void; + subscribe(listener: ChatStoreListener): () => void; + isReady(): boolean; + getConnectionLabel(): string; + + createRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string, description?: string): Promise; + getRoom(roomId: string): Promise; + getRoomByName(name: string): Promise; + getAllRooms(): Promise; + getUserRooms(userId: string): Promise; + getSearchableRooms(userId: string, query: string): Promise; + ensureRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string): Promise; + + joinRoom(roomId: string, userId: string, userName: string): Promise; + leaveRoom(roomId: string, userId: string): Promise; + getRoomMembers(roomId: string): Promise; + isMember(roomId: string, userId: string): Promise; + + sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise; + getMessages(roomId: string, limit?: number): Promise; +} + +// ─── ALASql local store ────────────────────────────────────────────────────── + +const CROSS_TAB_EVENT = "chatbox-v2-update"; + +export class LocalChatStore implements IChatStore { private dbName: string; private ready = false; private listeners = new Set(); @@ -54,7 +73,8 @@ export class ChatDataStore { this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`; } - // ── Lifecycle ──────────────────────────────────────────────────────────── + isReady(): boolean { return this.ready; } + getConnectionLabel(): string { return this.ready ? "Local" : "Connecting..."; } async init(): Promise { if (this.ready) return; @@ -66,31 +86,19 @@ export class ChatDataStore { await alasql.promise(` CREATE TABLE IF NOT EXISTS rooms ( - id STRING PRIMARY KEY, - name STRING, - description STRING, - type STRING, - creatorId STRING, - createdAt NUMBER, - updatedAt NUMBER + id STRING PRIMARY KEY, name STRING, description STRING, + type STRING, creatorId STRING, createdAt NUMBER, updatedAt NUMBER ) `); await alasql.promise(` CREATE TABLE IF NOT EXISTS messages ( - id STRING PRIMARY KEY, - roomId STRING, - authorId STRING, - authorName STRING, - text STRING, - timestamp NUMBER + id STRING PRIMARY KEY, roomId STRING, authorId STRING, + authorName STRING, text STRING, timestamp NUMBER ) `); await alasql.promise(` CREATE TABLE IF NOT EXISTS members ( - roomId STRING, - userId STRING, - userName STRING, - joinedAt NUMBER + roomId STRING, userId STRING, userName STRING, joinedAt NUMBER ) `); this.ready = true; @@ -107,8 +115,6 @@ export class ChatDataStore { this.listeners.clear(); } - // ── Subscriptions (React-friendly) ─────────────────────────────────────── - subscribe(listener: ChatStoreListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -117,11 +123,7 @@ export class ChatDataStore { private notify(): void { this.listeners.forEach((fn) => fn()); if (typeof window !== "undefined") { - try { - window.dispatchEvent(new CustomEvent(CROSS_TAB_EVENT)); - } catch { - // CustomEvent not supported in test environments - } + try { window.dispatchEvent(new CustomEvent(CROSS_TAB_EVENT)); } catch { /* noop */ } } } @@ -129,57 +131,37 @@ export class ChatDataStore { this.listeners.forEach((fn) => fn()); }; - // ── Rooms ──────────────────────────────────────────────────────────────── - - async createRoom( - name: string, - type: "public" | "private", - creatorId: string, - creatorName: string, - description = "", - ): Promise { - this.assertReady(); - const id = this.uid(); + // ── Rooms ────────────────────────────────────────────────────────────── + + async createRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string, description = ""): Promise { + this.assert(); + const id = uid(); const now = Date.now(); - await alasql.promise( - `INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?)`, - [id, name, description, type, creatorId, now, now], - ); - await alasql.promise( - `INSERT INTO members VALUES (?, ?, ?, ?)`, - [id, creatorId, creatorName, now], - ); + await alasql.promise(`INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?)`, [id, name, description, type, creatorId, now, now]); + await alasql.promise(`INSERT INTO members VALUES (?, ?, ?, ?)`, [id, creatorId, creatorName, now]); this.notify(); return { id, name, description, type, creatorId, createdAt: now, updatedAt: now }; } async getRoom(roomId: string): Promise { - this.assertReady(); - const rows = (await alasql.promise( - `SELECT * FROM rooms WHERE id = ?`, - [roomId], - )) as ChatRoom[]; + this.assert(); + const rows = (await alasql.promise(`SELECT * FROM rooms WHERE id = ?`, [roomId])) as ChatRoom[]; return rows.length > 0 ? rows[0] : null; } async getRoomByName(name: string): Promise { - this.assertReady(); - const rows = (await alasql.promise( - `SELECT * FROM rooms WHERE name = ?`, - [name], - )) as ChatRoom[]; + this.assert(); + const rows = (await alasql.promise(`SELECT * FROM rooms WHERE name = ?`, [name])) as ChatRoom[]; return rows.length > 0 ? rows[0] : null; } async getAllRooms(): Promise { - this.assertReady(); - return (await alasql.promise( - `SELECT * FROM rooms ORDER BY updatedAt DESC`, - )) as ChatRoom[]; + this.assert(); + return (await alasql.promise(`SELECT * FROM rooms ORDER BY updatedAt DESC`)) as ChatRoom[]; } async getUserRooms(userId: string): Promise { - this.assertReady(); + this.assert(); return (await alasql.promise( `SELECT r.* FROM rooms r JOIN members m ON r.id = m.roomId WHERE m.userId = ? ORDER BY r.updatedAt DESC`, [userId], @@ -187,7 +169,7 @@ export class ChatDataStore { } async getSearchableRooms(userId: string, query: string): Promise { - this.assertReady(); + this.assert(); const q = `%${query}%`; return (await alasql.promise( `SELECT DISTINCT r.* FROM rooms r @@ -199,124 +181,455 @@ export class ChatDataStore { )) as ChatRoom[]; } - // ── Membership ─────────────────────────────────────────────────────────── + async ensureRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string): Promise { + let room = await this.getRoomByName(name); + if (!room) room = await this.createRoom(name, type, creatorId, creatorName); + if (!(await this.isMember(room.id, creatorId))) await this.joinRoom(room.id, creatorId, creatorName); + return room; + } + + // ── Membership ───────────────────────────────────────────────────────── async joinRoom(roomId: string, userId: string, userName: string): Promise { - this.assertReady(); - const existing = (await alasql.promise( - `SELECT * FROM members WHERE roomId = ? AND userId = ?`, - [roomId, userId], - )) as RoomMember[]; - if (existing.length > 0) return true; // already a member - await alasql.promise( - `INSERT INTO members VALUES (?, ?, ?, ?)`, - [roomId, userId, userName, Date.now()], - ); + this.assert(); + const existing = (await alasql.promise(`SELECT * FROM members WHERE roomId = ? AND userId = ?`, [roomId, userId])) as RoomMember[]; + if (existing.length > 0) return true; + await alasql.promise(`INSERT INTO members VALUES (?, ?, ?, ?)`, [roomId, userId, userName, Date.now()]); this.notify(); return true; } async leaveRoom(roomId: string, userId: string): Promise { - this.assertReady(); - await alasql.promise( - `DELETE FROM members WHERE roomId = ? AND userId = ?`, - [roomId, userId], - ); + this.assert(); + await alasql.promise(`DELETE FROM members WHERE roomId = ? AND userId = ?`, [roomId, userId]); this.notify(); return true; } async getRoomMembers(roomId: string): Promise { - this.assertReady(); - return (await alasql.promise( - `SELECT * FROM members WHERE roomId = ? ORDER BY joinedAt ASC`, - [roomId], - )) as RoomMember[]; + this.assert(); + return (await alasql.promise(`SELECT * FROM members WHERE roomId = ? ORDER BY joinedAt ASC`, [roomId])) as RoomMember[]; } async isMember(roomId: string, userId: string): Promise { - this.assertReady(); - const rows = (await alasql.promise( - `SELECT * FROM members WHERE roomId = ? AND userId = ?`, - [roomId, userId], - )) as RoomMember[]; + this.assert(); + const rows = (await alasql.promise(`SELECT * FROM members WHERE roomId = ? AND userId = ?`, [roomId, userId])) as RoomMember[]; return rows.length > 0; } - // ── Messages ───────────────────────────────────────────────────────────── - - async sendMessage( - roomId: string, - authorId: string, - authorName: string, - text: string, - ): Promise { - this.assertReady(); - const msg: ChatMessage = { - id: this.uid(), - roomId, - authorId, - authorName, - text, - timestamp: Date.now(), - }; - await alasql.promise( - `INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)`, - [msg.id, msg.roomId, msg.authorId, msg.authorName, msg.text, msg.timestamp], - ); - await alasql.promise( - `UPDATE rooms SET updatedAt = ? WHERE id = ?`, - [msg.timestamp, roomId], - ); + // ── Messages ─────────────────────────────────────────────────────────── + + async sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise { + this.assert(); + const msg: ChatMessage = { id: uid(), roomId, authorId, authorName, text, timestamp: Date.now() }; + await alasql.promise(`INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)`, [msg.id, msg.roomId, msg.authorId, msg.authorName, msg.text, msg.timestamp]); + await alasql.promise(`UPDATE rooms SET updatedAt = ? WHERE id = ?`, [msg.timestamp, roomId]); this.notify(); return msg; } async getMessages(roomId: string, limit = 100): Promise { - this.assertReady(); - const rows = (await alasql.promise( - `SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC`, - [roomId], - )) as ChatMessage[]; + this.assert(); + const rows = (await alasql.promise(`SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC`, [roomId])) as ChatMessage[]; return rows.slice(-limit); } - // ── Or-create helpers (for initial room setup) ─────────────────────────── + // ── Internal ─────────────────────────────────────────────────────────── + + private assert(): void { + if (!this.ready) throw new Error("LocalChatStore not initialized. Call init() first."); + } +} + +// ─── Yjs collaborative store ──────────────────────────────────────────────── + +import * as Y from "yjs"; +import { WebsocketProvider } from "y-websocket"; + +export class YjsChatStore implements IChatStore { + private ydoc: Y.Doc | null = null; + private wsProvider: WebsocketProvider | null = null; + private messagesMap: Y.Map | null = null; + private roomsMap: Y.Map | null = null; + private membersMap: Y.Map | null = null; + private listeners = new Set(); + private ready = false; + private wsConnected = false; + + private applicationId: string; + private wsUrl: string; + + // Shared doc/provider cache so multiple components on same page reuse the connection. + private static docs = new Map(); + private static providers = new Map(); + private static refCounts = new Map(); + + constructor(applicationId: string, wsUrl: string) { + this.applicationId = applicationId; + this.wsUrl = wsUrl; + } + + isReady(): boolean { return this.ready; } + getConnectionLabel(): string { + if (!this.ready) return "Connecting..."; + return this.wsConnected ? "Online" : "Offline (local Yjs)"; + } + + async init(): Promise { + if (this.ready) return; + + const docId = `chatv2_${this.applicationId}`; + + let ydoc = YjsChatStore.docs.get(docId); + let wsProvider = YjsChatStore.providers.get(docId); + + if (!ydoc) { + ydoc = new Y.Doc(); + YjsChatStore.docs.set(docId, ydoc); + YjsChatStore.refCounts.set(docId, 1); + + wsProvider = new WebsocketProvider(this.wsUrl, docId, ydoc, { connect: true }); + YjsChatStore.providers.set(docId, wsProvider); + } else { + YjsChatStore.refCounts.set(docId, (YjsChatStore.refCounts.get(docId) || 0) + 1); + wsProvider = YjsChatStore.providers.get(docId)!; + } + + this.ydoc = ydoc; + this.wsProvider = wsProvider; + this.messagesMap = ydoc.getMap("messages"); + this.roomsMap = ydoc.getMap("rooms"); + this.membersMap = ydoc.getMap("members"); + + // React to any Yjs mutation → notify listeners + const onChange = () => this.notify(); + this.messagesMap.observe(onChange); + this.roomsMap.observe(onChange); + this.membersMap.observe(onChange); + + if (wsProvider) { + wsProvider.on("status", (e: { status: string }) => { + this.wsConnected = e.status === "connected"; + this.notify(); + }); + this.wsConnected = wsProvider.wsconnected; + } + + this.ready = true; + this.notify(); + } + + destroy(): void { + if (this.ydoc) { + const docId = `chatv2_${this.applicationId}`; + const count = (YjsChatStore.refCounts.get(docId) || 1) - 1; + if (count <= 0) { + YjsChatStore.providers.get(docId)?.destroy(); + YjsChatStore.providers.delete(docId); + YjsChatStore.docs.delete(docId); + YjsChatStore.refCounts.delete(docId); + } else { + YjsChatStore.refCounts.set(docId, count); + } + } + this.ydoc = null; + this.wsProvider = null; + this.messagesMap = null; + this.roomsMap = null; + this.membersMap = null; + this.listeners.clear(); + this.ready = false; + } + + subscribe(listener: ChatStoreListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + this.listeners.forEach((fn) => fn()); + } + + // ── Rooms ────────────────────────────────────────────────────────────── + + async createRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string, description = ""): Promise { + this.assert(); + const id = uid(); + const now = Date.now(); + const room: ChatRoom = { id, name, description, type, creatorId, createdAt: now, updatedAt: now }; + this.roomsMap!.set(id, room); + // Also add creator as member + this.addMemberEntry(id, creatorId, creatorName, now); + return room; + } + + async getRoom(roomId: string): Promise { + this.assert(); + return (this.roomsMap!.get(roomId) as ChatRoom) ?? null; + } + + async getRoomByName(name: string): Promise { + this.assert(); + for (const room of this.roomsMap!.values()) { + if ((room as ChatRoom).name === name) return room as ChatRoom; + } + return null; + } + + async getAllRooms(): Promise { + this.assert(); + const rooms: ChatRoom[] = []; + this.roomsMap!.forEach((v) => rooms.push(v as ChatRoom)); + rooms.sort((a, b) => b.updatedAt - a.updatedAt); + return rooms; + } + + async getUserRooms(userId: string): Promise { + this.assert(); + const memberRoomIds = new Set(); + this.membersMap!.forEach((v: any, key: string) => { + if (v.userId === userId) memberRoomIds.add(v.roomId); + }); + const rooms: ChatRoom[] = []; + this.roomsMap!.forEach((v) => { + const r = v as ChatRoom; + if (memberRoomIds.has(r.id)) rooms.push(r); + }); + rooms.sort((a, b) => b.updatedAt - a.updatedAt); + return rooms; + } + + async getSearchableRooms(userId: string, query: string): Promise { + this.assert(); + const memberRoomIds = new Set(); + this.membersMap!.forEach((v: any) => { + if (v.userId === userId) memberRoomIds.add(v.roomId); + }); + const lq = query.toLowerCase(); + const rooms: ChatRoom[] = []; + this.roomsMap!.forEach((v) => { + const r = v as ChatRoom; + if (r.type !== "public") return; + if (memberRoomIds.has(r.id)) return; + if (r.name.toLowerCase().includes(lq) || r.description.toLowerCase().includes(lq)) { + rooms.push(r); + } + }); + rooms.sort((a, b) => b.updatedAt - a.updatedAt); + return rooms; + } - async ensureRoom( - name: string, - type: "public" | "private", - creatorId: string, - creatorName: string, - ): Promise { + async ensureRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string): Promise { let room = await this.getRoomByName(name); - if (!room) { - room = await this.createRoom(name, type, creatorId, creatorName); + if (!room) room = await this.createRoom(name, type, creatorId, creatorName); + if (!(await this.isMember(room.id, creatorId))) await this.joinRoom(room.id, creatorId, creatorName); + return room; + } + + // ── Membership ───────────────────────────────────────────────────────── + + private memberKey(roomId: string, userId: string) { return `${roomId}::${userId}`; } + + private addMemberEntry(roomId: string, userId: string, userName: string, joinedAt: number) { + this.membersMap!.set(this.memberKey(roomId, userId), { roomId, userId, userName, joinedAt } as RoomMember); + } + + async joinRoom(roomId: string, userId: string, userName: string): Promise { + this.assert(); + const key = this.memberKey(roomId, userId); + if (this.membersMap!.has(key)) return true; + this.addMemberEntry(roomId, userId, userName, Date.now()); + return true; + } + + async leaveRoom(roomId: string, userId: string): Promise { + this.assert(); + this.membersMap!.delete(this.memberKey(roomId, userId)); + return true; + } + + async getRoomMembers(roomId: string): Promise { + this.assert(); + const members: RoomMember[] = []; + this.membersMap!.forEach((v: any) => { + if (v.roomId === roomId) members.push(v as RoomMember); + }); + members.sort((a, b) => a.joinedAt - b.joinedAt); + return members; + } + + async isMember(roomId: string, userId: string): Promise { + this.assert(); + return this.membersMap!.has(this.memberKey(roomId, userId)); + } + + // ── Messages ─────────────────────────────────────────────────────────── + + async sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise { + this.assert(); + const msg: ChatMessage = { id: uid(), roomId, authorId, authorName, text, timestamp: Date.now() }; + this.messagesMap!.set(msg.id, msg); + // Touch room updatedAt + const room = this.roomsMap!.get(roomId) as ChatRoom | undefined; + if (room) { + this.roomsMap!.set(roomId, { ...room, updatedAt: msg.timestamp }); } - const member = await this.isMember(room.id, creatorId); - if (!member) { - await this.joinRoom(room.id, creatorId, creatorName); + return msg; + } + + async getMessages(roomId: string, limit = 100): Promise { + this.assert(); + const msgs: ChatMessage[] = []; + this.messagesMap!.forEach((v) => { + const m = v as ChatMessage; + if (m.roomId === roomId) msgs.push(m); + }); + msgs.sort((a, b) => a.timestamp - b.timestamp); + return msgs.slice(-limit); + } + + // ── Internal ─────────────────────────────────────────────────────────── + + private assert(): void { + if (!this.ready) throw new Error("YjsChatStore not initialized. Call init() first."); + } +} + +// ─── Hybrid store (local + Yjs with fallback) ─────────────────────────────── + +/** + * Wraps both a LocalChatStore and a YjsChatStore. Writes go to both; + * reads prefer Yjs when the WebSocket is connected, otherwise fall back to + * local. This gives offline-capable persistence with real-time sync when + * the server is reachable. + */ +export class HybridChatStore implements IChatStore { + private local: LocalChatStore; + private yjs: YjsChatStore; + private listeners = new Set(); + private ready = false; + + constructor(applicationId: string, wsUrl: string) { + this.local = new LocalChatStore(applicationId); + this.yjs = new YjsChatStore(applicationId, wsUrl); + } + + isReady(): boolean { return this.ready; } + getConnectionLabel(): string { + if (!this.ready) return "Connecting..."; + const yjsLabel = this.yjs.getConnectionLabel(); + return `Hybrid (${yjsLabel})`; + } + + async init(): Promise { + if (this.ready) return; + // Local always succeeds; Yjs may fail (no server) but we don't block on it. + await this.local.init(); + try { await this.yjs.init(); } catch { /* yjs offline, that's fine */ } + this.ready = true; + + this.local.subscribe(() => this.notify()); + this.yjs.subscribe(() => this.notify()); + } + + destroy(): void { + this.local.destroy(); + this.yjs.destroy(); + this.listeners.clear(); + this.ready = false; + } + + subscribe(listener: ChatStoreListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { this.listeners.forEach((fn) => fn()); } + + // Prefer Yjs for reads when it's ready, fallback to local. + private get reader(): IChatStore { return this.yjs.isReady() ? this.yjs : this.local; } + + // ── Rooms (write to both, read from best available) ──────────────────── + + async createRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string, description?: string): Promise { + const room = await this.local.createRoom(name, type, creatorId, creatorName, description); + if (this.yjs.isReady()) { + try { await this.yjs.createRoom(name, type, creatorId, creatorName, description); } catch { /* offline */ } + } + return room; + } + + async getRoom(roomId: string) { return this.reader.getRoom(roomId); } + async getRoomByName(name: string) { return this.reader.getRoomByName(name); } + async getAllRooms() { return this.reader.getAllRooms(); } + async getUserRooms(userId: string) { return this.reader.getUserRooms(userId); } + async getSearchableRooms(userId: string, query: string) { return this.reader.getSearchableRooms(userId, query); } + + async ensureRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string): Promise { + const room = await this.local.ensureRoom(name, type, creatorId, creatorName); + if (this.yjs.isReady()) { + try { await this.yjs.ensureRoom(name, type, creatorId, creatorName); } catch { /* offline */ } } return room; } - // ── Internals ──────────────────────────────────────────────────────────── + // ── Membership (write to both) ───────────────────────────────────────── + + async joinRoom(roomId: string, userId: string, userName: string): Promise { + const ok = await this.local.joinRoom(roomId, userId, userName); + if (this.yjs.isReady()) { try { await this.yjs.joinRoom(roomId, userId, userName); } catch { /* offline */ } } + return ok; + } - private assertReady(): void { - if (!this.ready) throw new Error("ChatDataStore not initialized. Call init() first."); + async leaveRoom(roomId: string, userId: string): Promise { + const ok = await this.local.leaveRoom(roomId, userId); + if (this.yjs.isReady()) { try { await this.yjs.leaveRoom(roomId, userId); } catch { /* offline */ } } + return ok; } - private uid(): string { - return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + async getRoomMembers(roomId: string) { return this.reader.getRoomMembers(roomId); } + async isMember(roomId: string, userId: string) { return this.reader.isMember(roomId, userId); } + + // ── Messages (write to both, read from best) ────────────────────────── + + async sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise { + const msg = await this.local.sendMessage(roomId, authorId, authorName, text); + if (this.yjs.isReady()) { + try { await this.yjs.sendMessage(roomId, authorId, authorName, text); } catch { /* offline */ } + } + return msg; } + + async getMessages(roomId: string, limit?: number) { return this.reader.getMessages(roomId, limit); } } -// Global cache keyed by applicationId so multiple components share one store. -const storeCache = new Map(); +// ─── Helpers & cache ───────────────────────────────────────────────────────── -export function getChatStore(applicationId: string): ChatDataStore { - if (!storeCache.has(applicationId)) { - storeCache.set(applicationId, new ChatDataStore(applicationId)); +function uid(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + +const storeCache = new Map(); + +export function getChatStore( + applicationId: string, + mode: SyncMode = "local", + wsUrl = "ws://localhost:3005", +): IChatStore { + const key = `${applicationId}__${mode}`; + if (!storeCache.has(key)) { + let store: IChatStore; + switch (mode) { + case "collaborative": + store = new YjsChatStore(applicationId, wsUrl); + break; + case "hybrid": + store = new HybridChatStore(applicationId, wsUrl); + break; + default: + store = new LocalChatStore(applicationId); + } + storeCache.set(key, store); } - return storeCache.get(applicationId)!; + return storeCache.get(key)!; } diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts index ac2feaad8..5928f8c27 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -1,16 +1,26 @@ -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { ChatDataStore, ChatMessage, ChatRoom, RoomMember, getChatStore } from "./chatDataStore"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type IChatStore, + type ChatMessage, + type ChatRoom, + type RoomMember, + type SyncMode, + getChatStore, +} from "./chatDataStore"; export interface UseChatStoreConfig { applicationId: string; defaultRoom: string; userId: string; userName: string; + mode: SyncMode; + wsUrl: string; } export interface UseChatStoreReturn { ready: boolean; error: string | null; + connectionLabel: string; currentRoom: ChatRoom | null; messages: ChatMessage[]; @@ -26,18 +36,18 @@ export interface UseChatStoreReturn { } export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { - const { applicationId, defaultRoom, userId, userName } = config; + const { applicationId, defaultRoom, userId, userName, mode, wsUrl } = config; - const storeRef = useRef(null); + const storeRef = useRef(null); const [ready, setReady] = useState(false); const [error, setError] = useState(null); + const [connectionLabel, setConnectionLabel] = useState("Connecting..."); const [currentRoom, setCurrentRoom] = useState(null); const [messages, setMessages] = useState([]); const [userRooms, setUserRooms] = useState([]); const [currentRoomMembers, setCurrentRoomMembers] = useState([]); - // Track the "active room id" in a ref so callbacks always see the latest value. const activeRoomIdRef = useRef(null); // ── Refresh helpers ──────────────────────────────────────────────────── @@ -48,9 +58,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { try { const rooms = await store.getUserRooms(userId); setUserRooms(rooms); - } catch { - // non-fatal - } + } catch { /* non-fatal */ } }, [userId]); const refreshMessages = useCallback(async () => { @@ -60,9 +68,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { try { const msgs = await store.getMessages(roomId); setMessages(msgs); - } catch { - // non-fatal - } + } catch { /* non-fatal */ } }, []); const refreshMembers = useCallback(async () => { @@ -72,13 +78,13 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { try { const members = await store.getRoomMembers(roomId); setCurrentRoomMembers(members); - } catch { - // non-fatal - } + } catch { /* non-fatal */ } }, []); const refreshAll = useCallback(async () => { await Promise.all([refreshRooms(), refreshMessages(), refreshMembers()]); + const store = storeRef.current; + if (store) setConnectionLabel(store.getConnectionLabel()); }, [refreshRooms, refreshMessages, refreshMembers]); // ── Initialization ───────────────────────────────────────────────────── @@ -87,7 +93,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { if (!applicationId || !userId || !userName) return; let cancelled = false; - const store = getChatStore(applicationId); + const store = getChatStore(applicationId, mode, wsUrl); storeRef.current = store; (async () => { @@ -95,7 +101,6 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { await store.init(); if (cancelled) return; - // Ensure the default room exists and user is a member. const room = await store.ensureRoom(defaultRoom, "public", userId, userName); if (cancelled) return; @@ -112,6 +117,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { setMessages(msgs); setUserRooms(rooms); setCurrentRoomMembers(members); + setConnectionLabel(store.getConnectionLabel()); setReady(true); } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : "Failed to initialize chat store"); @@ -126,7 +132,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { cancelled = true; unsub(); }; - }, [applicationId, userId, userName, defaultRoom, refreshAll]); + }, [applicationId, userId, userName, defaultRoom, mode, wsUrl, refreshAll]); // ── Actions ──────────────────────────────────────────────────────────── @@ -138,9 +144,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { try { await store.sendMessage(roomId, userId, userName, text.trim()); return true; - } catch { - return false; - } + } catch { return false; } }, [userId, userName], ); @@ -167,12 +171,8 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { async (name: string, type: "public" | "private", description?: string): Promise => { const store = storeRef.current; if (!store) return null; - try { - const room = await store.createRoom(name, type, userId, userName, description); - return room; - } catch { - return null; - } + try { return await store.createRoom(name, type, userId, userName, description); } + catch { return null; } }, [userId, userName], ); @@ -185,9 +185,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { const ok = await store.joinRoom(roomId, userId, userName); if (ok) await switchRoom(roomId); return ok; - } catch { - return false; - } + } catch { return false; } }, [userId, userName, switchRoom], ); @@ -210,9 +208,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { } } return ok; - } catch { - return false; - } + } catch { return false; } }, [userId, switchRoom], ); @@ -221,11 +217,8 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { async (query: string): Promise => { const store = storeRef.current; if (!store || !query.trim()) return []; - try { - return await store.getSearchableRooms(userId, query.trim()); - } catch { - return []; - } + try { return await store.getSearchableRooms(userId, query.trim()); } + catch { return []; } }, [userId], ); @@ -233,6 +226,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { return { ready, error, + connectionLabel, currentRoom, messages, userRooms, From 362e362499e163e09e10bc72925e2d84056ffb18 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Wed, 4 Mar 2026 01:18:00 +0500 Subject: [PATCH 18/34] fix linter errors --- .../src/comps/comps/chatBoxComponentv2/chatDataStore.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts index 5b1b13a7c..d808f542d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts @@ -1,4 +1,6 @@ import alasql from "alasql"; +import * as Y from "yjs"; +import { WebsocketProvider } from "y-websocket"; // ─── Shared types ──────────────────────────────────────────────────────────── @@ -243,9 +245,6 @@ export class LocalChatStore implements IChatStore { // ─── Yjs collaborative store ──────────────────────────────────────────────── -import * as Y from "yjs"; -import { WebsocketProvider } from "y-websocket"; - export class YjsChatStore implements IChatStore { private ydoc: Y.Doc | null = null; private wsProvider: WebsocketProvider | null = null; From 6a1911b4b359e5fe2cecc6dfc7d5a02f7fd28d86 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Wed, 4 Mar 2026 01:38:06 +0500 Subject: [PATCH 19/34] add typing indicators --- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 111 +++++++++++++++++- .../comps/chatBoxComponentv2/chatDataStore.ts | 99 ++++++++++++++++ .../comps/chatBoxComponentv2/useChatStore.ts | 41 ++++++- 3 files changed, 246 insertions(+), 5 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index 28fc169c4..45ac146c0 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -28,7 +28,7 @@ import { EditorContext } from "comps/editorState"; import { trans } from "i18n"; import { useChatStore } from "./useChatStore"; -import type { ChatMessage, ChatRoom, RoomMember, SyncMode } from "./chatDataStore"; +import type { ChatMessage, ChatRoom, RoomMember, SyncMode, TypingUser } from "./chatDataStore"; // ─── Event definitions ────────────────────────────────────────────────────── @@ -211,6 +211,46 @@ const EmptyChat = styled.div` gap: 4px; `; +const TypingIndicatorWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + align-self: flex-start; +`; + +const TypingDots = styled.span` + display: inline-flex; + align-items: center; + gap: 3px; + background: #e8e8e8; + border-radius: 12px; + padding: 8px 12px; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: #999; + animation: typingBounce 1.4s infinite ease-in-out both; + } + + span:nth-child(1) { animation-delay: 0s; } + span:nth-child(2) { animation-delay: 0.2s; } + span:nth-child(3) { animation-delay: 0.4s; } + + @keyframes typingBounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } + } +`; + +const TypingLabel = styled.span` + font-size: 12px; + color: #999; + font-style: italic; +`; + const SearchResultBadge = styled.span` font-size: 10px; background: #e6f7ff; @@ -257,6 +297,8 @@ const ChatBoxView = React.memo((props: any) => { const [isSearchMode, setIsSearchMode] = useState(false); const [createForm] = Form.useForm(); const messagesEndRef = useRef(null); + const typingTimeoutRef = useRef | null>(null); + const isTypingRef = useRef(false); // Auto-scroll on new messages useEffect(() => { @@ -265,14 +307,30 @@ const ChatBoxView = React.memo((props: any) => { // ── Handlers ─────────────────────────────────────────────────────────── + const clearTypingTimeout = useCallback(() => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + }, []); + + const handleStopTyping = useCallback(() => { + clearTypingTimeout(); + if (isTypingRef.current) { + isTypingRef.current = false; + chat.stopTyping(); + } + }, [chat.stopTyping, clearTypingTimeout]); + const handleSend = useCallback(async () => { if (!draft.trim()) return; + handleStopTyping(); const ok = await chat.sendMessage(draft); if (ok) { setDraft(""); onEvent("messageSent"); } - }, [draft, chat.sendMessage, onEvent]); + }, [draft, chat.sendMessage, onEvent, handleStopTyping]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -284,6 +342,39 @@ const ChatBoxView = React.memo((props: any) => { [handleSend], ); + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setDraft(value); + + if (!value.trim()) { + handleStopTyping(); + return; + } + + if (!isTypingRef.current) { + isTypingRef.current = true; + chat.startTyping(); + } + + clearTypingTimeout(); + typingTimeoutRef.current = setTimeout(() => { + handleStopTyping(); + }, 2000); + }, + [chat.startTyping, handleStopTyping, clearTypingTimeout], + ); + + // Cleanup typing timeout on unmount + useEffect(() => { + return () => { + clearTypingTimeout(); + if (isTypingRef.current) { + chat.stopTyping(); + } + }; + }, [chat.stopTyping, clearTypingTimeout]); + const handleCreateRoom = useCallback( async (values: { roomName: string; roomType: "public" | "private"; description?: string }) => { const room = await chat.createRoom(values.roomName.trim(), values.roomType, values.description); @@ -495,13 +586,27 @@ const ChatBoxView = React.memo((props: any) => { ); }) )} + {chat.typingUsers.length > 0 && ( + + + + + + + + {chat.typingUsers.length === 1 + ? `${chat.typingUsers[0].userName} is typing...` + : `${chat.typingUsers.length} people are typing...`} + + + )}
setDraft(e.target.value)} + onChange={handleInputChange} onKeyDown={handleKeyDown} placeholder={chat.ready ? "Type a message..." : "Connecting..."} disabled={!chat.ready || !chat.currentRoom} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts index d808f542d..2e7d638c0 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts @@ -30,6 +30,13 @@ export interface RoomMember { joinedAt: number; } +export interface TypingUser { + userId: string; + userName: string; + roomId: string; + startedAt: number; +} + export type ChatStoreListener = () => void; export type SyncMode = "local" | "collaborative" | "hybrid"; @@ -60,6 +67,10 @@ export interface IChatStore { sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise; getMessages(roomId: string, limit?: number): Promise; + + startTyping(roomId: string, userId: string, userName: string): void; + stopTyping(roomId: string, userId: string): void; + getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[]; } // ─── ALASql local store ────────────────────────────────────────────────────── @@ -70,6 +81,7 @@ export class LocalChatStore implements IChatStore { private dbName: string; private ready = false; private listeners = new Set(); + private typingMap = new Map(); constructor(applicationId: string) { this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`; @@ -236,6 +248,36 @@ export class LocalChatStore implements IChatStore { return rows.slice(-limit); } + // ── Typing ───────────────────────────────────────────────────────────── + + private typingKey(roomId: string, userId: string) { return `${roomId}::${userId}`; } + + startTyping(roomId: string, userId: string, userName: string): void { + this.typingMap.set(this.typingKey(roomId, userId), { userId, userName, roomId, startedAt: Date.now() }); + this.notify(); + } + + stopTyping(roomId: string, userId: string): void { + if (this.typingMap.delete(this.typingKey(roomId, userId))) { + this.notify(); + } + } + + getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { + const now = Date.now(); + const result: TypingUser[] = []; + for (const [key, entry] of this.typingMap) { + if (entry.roomId !== roomId) continue; + if (excludeUserId && entry.userId === excludeUserId) continue; + if (now - entry.startedAt > 5000) { + this.typingMap.delete(key); + continue; + } + result.push(entry); + } + return result; + } + // ── Internal ─────────────────────────────────────────────────────────── private assert(): void { @@ -251,6 +293,7 @@ export class YjsChatStore implements IChatStore { private messagesMap: Y.Map | null = null; private roomsMap: Y.Map | null = null; private membersMap: Y.Map | null = null; + private typingYMap: Y.Map | null = null; private listeners = new Set(); private ready = false; private wsConnected = false; @@ -299,12 +342,14 @@ export class YjsChatStore implements IChatStore { this.messagesMap = ydoc.getMap("messages"); this.roomsMap = ydoc.getMap("rooms"); this.membersMap = ydoc.getMap("members"); + this.typingYMap = ydoc.getMap("typing"); // React to any Yjs mutation → notify listeners const onChange = () => this.notify(); this.messagesMap.observe(onChange); this.roomsMap.observe(onChange); this.membersMap.observe(onChange); + this.typingYMap.observe(onChange); if (wsProvider) { wsProvider.on("status", (e: { status: string }) => { @@ -336,6 +381,7 @@ export class YjsChatStore implements IChatStore { this.messagesMap = null; this.roomsMap = null; this.membersMap = null; + this.typingYMap = null; this.listeners.clear(); this.ready = false; } @@ -487,6 +533,35 @@ export class YjsChatStore implements IChatStore { return msgs.slice(-limit); } + // ── Typing ───────────────────────────────────────────────────────────── + + private typingKey(roomId: string, userId: string) { return `${roomId}::${userId}`; } + + startTyping(roomId: string, userId: string, userName: string): void { + this.typingYMap?.set(this.typingKey(roomId, userId), { userId, userName, roomId, startedAt: Date.now() } as TypingUser); + } + + stopTyping(roomId: string, userId: string): void { + this.typingYMap?.delete(this.typingKey(roomId, userId)); + } + + getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { + if (!this.typingYMap) return []; + const now = Date.now(); + const result: TypingUser[] = []; + this.typingYMap.forEach((v: any, key: string) => { + const entry = v as TypingUser; + if (entry.roomId !== roomId) return; + if (excludeUserId && entry.userId === excludeUserId) return; + if (now - entry.startedAt > 5000) { + this.typingYMap!.delete(key); + return; + } + result.push(entry); + }); + return result; + } + // ── Internal ─────────────────────────────────────────────────────────── private assert(): void { @@ -600,6 +675,30 @@ export class HybridChatStore implements IChatStore { } async getMessages(roomId: string, limit?: number) { return this.reader.getMessages(roomId, limit); } + + // ── Typing (prefer Yjs for real-time sync, fallback to local) ───────── + + startTyping(roomId: string, userId: string, userName: string): void { + if (this.yjs.isReady()) { + this.yjs.startTyping(roomId, userId, userName); + } else { + this.local.startTyping(roomId, userId, userName); + } + } + + stopTyping(roomId: string, userId: string): void { + if (this.yjs.isReady()) { + this.yjs.stopTyping(roomId, userId); + } else { + this.local.stopTyping(roomId, userId); + } + } + + getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { + return this.yjs.isReady() + ? this.yjs.getTypingUsers(roomId, excludeUserId) + : this.local.getTypingUsers(roomId, excludeUserId); + } } // ─── Helpers & cache ───────────────────────────────────────────────────────── diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts index 5928f8c27..04fd172f2 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -4,6 +4,7 @@ import { type ChatMessage, type ChatRoom, type RoomMember, + type TypingUser, type SyncMode, getChatStore, } from "./chatDataStore"; @@ -26,6 +27,7 @@ export interface UseChatStoreReturn { messages: ChatMessage[]; userRooms: ChatRoom[]; currentRoomMembers: RoomMember[]; + typingUsers: TypingUser[]; sendMessage: (text: string) => Promise; switchRoom: (roomId: string) => Promise; @@ -33,6 +35,8 @@ export interface UseChatStoreReturn { joinRoom: (roomId: string) => Promise; leaveRoom: (roomId: string) => Promise; searchRooms: (query: string) => Promise; + startTyping: () => void; + stopTyping: () => void; } export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { @@ -47,8 +51,10 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { const [messages, setMessages] = useState([]); const [userRooms, setUserRooms] = useState([]); const [currentRoomMembers, setCurrentRoomMembers] = useState([]); + const [typingUsers, setTypingUsers] = useState([]); const activeRoomIdRef = useRef(null); + const typingPollRef = useRef | null>(null); // ── Refresh helpers ──────────────────────────────────────────────────── @@ -81,11 +87,19 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { } catch { /* non-fatal */ } }, []); + const refreshTyping = useCallback(() => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + setTypingUsers(store.getTypingUsers(roomId, userId)); + }, [userId]); + const refreshAll = useCallback(async () => { await Promise.all([refreshRooms(), refreshMessages(), refreshMembers()]); + refreshTyping(); const store = storeRef.current; if (store) setConnectionLabel(store.getConnectionLabel()); - }, [refreshRooms, refreshMessages, refreshMembers]); + }, [refreshRooms, refreshMessages, refreshMembers, refreshTyping]); // ── Initialization ───────────────────────────────────────────────────── @@ -128,11 +142,17 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { if (!cancelled) refreshAll(); }); + // Poll typing state every 1.5s to auto-expire stale entries + typingPollRef.current = setInterval(() => { + if (!cancelled) refreshTyping(); + }, 1500); + return () => { cancelled = true; unsub(); + if (typingPollRef.current) clearInterval(typingPollRef.current); }; - }, [applicationId, userId, userName, defaultRoom, mode, wsUrl, refreshAll]); + }, [applicationId, userId, userName, defaultRoom, mode, wsUrl, refreshAll, refreshTyping]); // ── Actions ──────────────────────────────────────────────────────────── @@ -223,6 +243,20 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { [userId], ); + const startTyping = useCallback(() => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + store.startTyping(roomId, userId, userName); + }, [userId, userName]); + + const stopTyping = useCallback(() => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + store.stopTyping(roomId, userId); + }, [userId]); + return { ready, error, @@ -231,11 +265,14 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { messages, userRooms, currentRoomMembers, + typingUsers, sendMessage, switchRoom, createRoom, joinRoom, leaveRoom, searchRooms, + startTyping, + stopTyping, }; } From bf08ee33c5b652f5ed2cd4dd2a0de78ff952c28a Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 5 Mar 2026 02:24:57 +0500 Subject: [PATCH 20/34] refactor chatbox styles, modes and fix registry --- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 629 +-------------- .../comps/chatBoxComponentv2/chatDataStore.ts | 733 ------------------ .../store/HybridChatStore.ts | 179 +++++ .../store/LocalChatStore.ts | 304 ++++++++ .../chatBoxComponentv2/store/YjsChatStore.ts | 336 ++++++++ .../comps/comps/chatBoxComponentv2/styles.ts | 209 +++++ .../comps/chatBoxComponentv2/useChatStore.ts | 145 ++-- client/packages/lowcoder/src/comps/index.tsx | 2 +- .../lowcoder/src/comps/uiCompRegistry.ts | 2 +- 9 files changed, 1129 insertions(+), 1410 deletions(-) delete mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/HybridChatStore.ts create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/LocalChatStore.ts create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/YjsChatStore.ts create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index 45ac146c0..34981603f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -1,16 +1,4 @@ -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import styled, { css } from "styled-components"; -import { Button, Input, Modal, Form, Radio, Space, Tooltip, Popconfirm } from "antd"; -import { - PlusOutlined, - SearchOutlined, - GlobalOutlined, - LockOutlined, - UserOutlined, - LogoutOutlined, - SendOutlined, -} from "@ant-design/icons"; - +import React, { useContext } from "react"; import { Section, sectionNames } from "lowcoder-design"; import { UICompBuilder, withDefault } from "../../generators"; import { NameConfig, NameConfigHidden, withExposingConfigs } from "../../generators/withExposing"; @@ -22,13 +10,12 @@ import { dropdownControl } from "comps/controls/dropdownControl"; import { AutoHeightControl } from "comps/controls/autoHeightControl"; import { eventHandlerControl } from "comps/controls/eventHandlerControl"; import { styleControl } from "comps/controls/styleControl"; -import { AnimationStyle, TextStyle, TextStyleType, AnimationStyleType } from "comps/controls/styleControlConstants"; +import { AnimationStyle, TextStyle } from "comps/controls/styleControlConstants"; import { hiddenPropertyView } from "comps/utils/propertyUtils"; import { EditorContext } from "comps/editorState"; import { trans } from "i18n"; -import { useChatStore } from "./useChatStore"; -import type { ChatMessage, ChatRoom, RoomMember, SyncMode, TypingUser } from "./chatDataStore"; +import { ChatBoxView } from "./components/ChatBoxView"; // ─── Event definitions ────────────────────────────────────────────────────── @@ -69,616 +56,6 @@ const childrenMap = { animationStyle: styleControl(AnimationStyle, "animationStyle"), }; -// ─── Styled components ────────────────────────────────────────────────────── - -const Wrapper = styled.div<{ $style: TextStyleType; $anim: AnimationStyleType }>` - height: 100%; - display: flex; - overflow: hidden; - border-radius: ${(p) => p.$style.radius || "8px"}; - border: ${(p) => p.$style.borderWidth || "1px"} solid ${(p) => p.$style.border || "#e0e0e0"}; - background: ${(p) => p.$style.background || "#fff"}; - font-family: ${(p) => p.$style.fontFamily || "inherit"}; - ${(p) => p.$anim} -`; - -const RoomPanel = styled.div<{ $width: string }>` - width: ${(p) => p.$width}; - min-width: 160px; - border-right: 1px solid #eee; - display: flex; - flex-direction: column; - background: #fafbfc; -`; - -const RoomPanelHeader = styled.div` - padding: 12px; - font-weight: 600; - font-size: 13px; - color: #555; - border-bottom: 1px solid #eee; - display: flex; - align-items: center; - justify-content: space-between; -`; - -const RoomList = styled.div` - flex: 1; - overflow-y: auto; - padding: 8px; -`; - -const RoomItemStyled = styled.div<{ $active: boolean }>` - padding: 8px 10px; - margin-bottom: 4px; - border-radius: 6px; - cursor: pointer; - font-size: 13px; - transition: all 0.15s ease; - display: flex; - align-items: center; - gap: 6px; - background: ${(p) => (p.$active ? "#1890ff" : "#fff")}; - color: ${(p) => (p.$active ? "#fff" : "#333")}; - border: 1px solid ${(p) => (p.$active ? "#1890ff" : "#f0f0f0")}; - - &:hover { - background: ${(p) => (p.$active ? "#1890ff" : "#f5f5f5")}; - } -`; - -const ChatPanel = styled.div` - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; -`; - -const ChatHeaderBar = styled.div` - padding: 12px 16px; - border-bottom: 1px solid #eee; - display: flex; - justify-content: space-between; - align-items: center; -`; - -const MessagesArea = styled.div` - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 8px; -`; - -const Bubble = styled.div<{ $own: boolean }>` - max-width: 70%; - padding: 10px 14px; - border-radius: 16px; - align-self: ${(p) => (p.$own ? "flex-end" : "flex-start")}; - background: ${(p) => (p.$own ? "#1890ff" : "#f0f0f0")}; - color: ${(p) => (p.$own ? "#fff" : "#333")}; - font-size: 14px; - word-break: break-word; -`; - -const BubbleMeta = styled.div<{ $own: boolean }>` - font-size: 11px; - opacity: 0.7; - margin-bottom: 2px; - text-align: ${(p) => (p.$own ? "right" : "left")}; -`; - -const BubbleTime = styled.div<{ $own: boolean }>` - font-size: 10px; - opacity: 0.6; - margin-top: 4px; - text-align: ${(p) => (p.$own ? "right" : "left")}; -`; - -const InputBar = styled.div` - padding: 12px 16px; - border-top: 1px solid #eee; - display: flex; - gap: 8px; - align-items: flex-end; -`; - -const StyledTextArea = styled.textarea` - flex: 1; - padding: 8px 14px; - border: 1px solid #d9d9d9; - border-radius: 18px; - resize: none; - min-height: 36px; - max-height: 96px; - font-size: 14px; - outline: none; - font-family: inherit; - line-height: 1.4; - &:focus { - border-color: #1890ff; - } -`; - -const EmptyChat = styled.div` - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: #999; - gap: 4px; -`; - -const TypingIndicatorWrapper = styled.div` - display: flex; - align-items: center; - gap: 8px; - padding: 4px 0; - align-self: flex-start; -`; - -const TypingDots = styled.span` - display: inline-flex; - align-items: center; - gap: 3px; - background: #e8e8e8; - border-radius: 12px; - padding: 8px 12px; - - span { - width: 6px; - height: 6px; - border-radius: 50%; - background: #999; - animation: typingBounce 1.4s infinite ease-in-out both; - } - - span:nth-child(1) { animation-delay: 0s; } - span:nth-child(2) { animation-delay: 0.2s; } - span:nth-child(3) { animation-delay: 0.4s; } - - @keyframes typingBounce { - 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } - 40% { transform: scale(1); opacity: 1; } - } -`; - -const TypingLabel = styled.span` - font-size: 12px; - color: #999; - font-style: italic; -`; - -const SearchResultBadge = styled.span` - font-size: 10px; - background: #e6f7ff; - color: #1890ff; - padding: 1px 6px; - border-radius: 8px; - font-weight: 500; - margin-left: auto; -`; - -// ─── View component ───────────────────────────────────────────────────────── - -const ChatBoxView = React.memo((props: any) => { - const { - chatName, - userId, - userName, - applicationId, - defaultRoom, - mode, - wsUrl, - allowRoomCreation, - allowRoomSearch, - showRoomPanel, - roomPanelWidth, - style, - animationStyle, - onEvent, - } = props; - - const chat = useChatStore({ - applicationId: applicationId.value || "lowcoder_app", - defaultRoom: defaultRoom || "general", - userId: userId.value || "user_1", - userName: userName.value || "User", - mode: (mode as SyncMode) || "local", - wsUrl: wsUrl || "ws://localhost:3005", - }); - - const [draft, setDraft] = useState(""); - const [createModalOpen, setCreateModalOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState([]); - const [isSearchMode, setIsSearchMode] = useState(false); - const [createForm] = Form.useForm(); - const messagesEndRef = useRef(null); - const typingTimeoutRef = useRef | null>(null); - const isTypingRef = useRef(false); - - // Auto-scroll on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [chat.messages]); - - // ── Handlers ─────────────────────────────────────────────────────────── - - const clearTypingTimeout = useCallback(() => { - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = null; - } - }, []); - - const handleStopTyping = useCallback(() => { - clearTypingTimeout(); - if (isTypingRef.current) { - isTypingRef.current = false; - chat.stopTyping(); - } - }, [chat.stopTyping, clearTypingTimeout]); - - const handleSend = useCallback(async () => { - if (!draft.trim()) return; - handleStopTyping(); - const ok = await chat.sendMessage(draft); - if (ok) { - setDraft(""); - onEvent("messageSent"); - } - }, [draft, chat.sendMessage, onEvent, handleStopTyping]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }, - [handleSend], - ); - - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - const value = e.target.value; - setDraft(value); - - if (!value.trim()) { - handleStopTyping(); - return; - } - - if (!isTypingRef.current) { - isTypingRef.current = true; - chat.startTyping(); - } - - clearTypingTimeout(); - typingTimeoutRef.current = setTimeout(() => { - handleStopTyping(); - }, 2000); - }, - [chat.startTyping, handleStopTyping, clearTypingTimeout], - ); - - // Cleanup typing timeout on unmount - useEffect(() => { - return () => { - clearTypingTimeout(); - if (isTypingRef.current) { - chat.stopTyping(); - } - }; - }, [chat.stopTyping, clearTypingTimeout]); - - const handleCreateRoom = useCallback( - async (values: { roomName: string; roomType: "public" | "private"; description?: string }) => { - const room = await chat.createRoom(values.roomName.trim(), values.roomType, values.description); - if (room) { - createForm.resetFields(); - setCreateModalOpen(false); - onEvent("roomJoined"); - } - }, - [chat.createRoom, createForm, onEvent], - ); - - const handleJoinRoom = useCallback( - async (roomId: string) => { - const ok = await chat.joinRoom(roomId); - if (ok) { - setSearchQuery(""); - setSearchResults([]); - setIsSearchMode(false); - onEvent("roomJoined"); - } - }, - [chat.joinRoom, onEvent], - ); - - const handleLeaveRoom = useCallback( - async (roomId: string) => { - const ok = await chat.leaveRoom(roomId); - if (ok) onEvent("roomLeft"); - }, - [chat.leaveRoom, onEvent], - ); - - const handleSearch = useCallback( - async (q: string) => { - setSearchQuery(q); - if (!q.trim()) { - setIsSearchMode(false); - setSearchResults([]); - return; - } - setIsSearchMode(true); - const results = await chat.searchRooms(q); - setSearchResults(results); - }, - [chat.searchRooms], - ); - - // ── Render ───────────────────────────────────────────────────────────── - - const roomListItems = isSearchMode ? searchResults : chat.userRooms; - - return ( - - {/* Room Panel */} - {showRoomPanel && ( - - - Rooms - {allowRoomCreation && ( - - -
- )} - - - {roomListItems.length === 0 && !isSearchMode && chat.ready && ( -
- No rooms yet. Create or search for one. -
- )} - - {roomListItems.map((room) => { - const isActive = chat.currentRoom?.id === room.id; - const isSearch = isSearchMode; - - return ( - { - if (isSearch) { - handleJoinRoom(room.id); - } else if (!isActive) { - chat.switchRoom(room.id); - } - }} - title={isSearch ? `Join "${room.name}"` : room.name} - > - {room.type === "public" ? ( - - ) : ( - - )} - - {room.name} - - {isSearch && Join} - {isActive && !isSearch && ( - { - e?.stopPropagation(); - handleLeaveRoom(room.id); - }} - onCancel={(e) => e?.stopPropagation()} - okText="Leave" - cancelText="Cancel" - okButtonProps={{ danger: true }} - > - e.stopPropagation()} - style={{ fontSize: 12, opacity: 0.7 }} - /> - - )} - - ); - })} -
- - )} - - {/* Chat Panel */} - - -
-
{chatName.value}
-
- {chat.currentRoom?.name || "No room selected"} - {chat.currentRoomMembers.length > 0 && ( - - - {chat.currentRoomMembers.length} - - )} -
-
-
- {chat.ready ? chat.connectionLabel : chat.error || "Connecting..."} -
-
- - - {chat.messages.length === 0 ? ( - -
💬
-
No messages yet
-
- {chat.ready ? "Start the conversation!" : "Connecting..."} -
-
- ) : ( - chat.messages.map((msg: ChatMessage) => { - const isOwn = msg.authorId === userId.value; - return ( -
- {msg.authorName} - {msg.text} - - {new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - -
- ); - }) - )} - {chat.typingUsers.length > 0 && ( - - - - - - - - {chat.typingUsers.length === 1 - ? `${chat.typingUsers[0].userName} is typing...` - : `${chat.typingUsers.length} people are typing...`} - - - )} -
- - - - - - - - - - - - ); -}); - -ChatBoxView.displayName = "ChatBoxV2View"; - // ─── Property panel ───────────────────────────────────────────────────────── const ChatBoxPropertyView = React.memo((props: { children: any }) => { diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts deleted file mode 100644 index 2e7d638c0..000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts +++ /dev/null @@ -1,733 +0,0 @@ -import alasql from "alasql"; -import * as Y from "yjs"; -import { WebsocketProvider } from "y-websocket"; - -// ─── Shared types ──────────────────────────────────────────────────────────── - -export interface ChatMessage { - id: string; - roomId: string; - authorId: string; - authorName: string; - text: string; - timestamp: number; -} - -export interface ChatRoom { - id: string; - name: string; - description: string; - type: "public" | "private"; - creatorId: string; - createdAt: number; - updatedAt: number; -} - -export interface RoomMember { - roomId: string; - userId: string; - userName: string; - joinedAt: number; -} - -export interface TypingUser { - userId: string; - userName: string; - roomId: string; - startedAt: number; -} - -export type ChatStoreListener = () => void; - -export type SyncMode = "local" | "collaborative" | "hybrid"; - -/** - * Common interface that both ALASql (local) and Yjs (collaborative) stores - * implement. The hook delegates to whichever is active. - */ -export interface IChatStore { - init(): Promise; - destroy(): void; - subscribe(listener: ChatStoreListener): () => void; - isReady(): boolean; - getConnectionLabel(): string; - - createRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string, description?: string): Promise; - getRoom(roomId: string): Promise; - getRoomByName(name: string): Promise; - getAllRooms(): Promise; - getUserRooms(userId: string): Promise; - getSearchableRooms(userId: string, query: string): Promise; - ensureRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string): Promise; - - joinRoom(roomId: string, userId: string, userName: string): Promise; - leaveRoom(roomId: string, userId: string): Promise; - getRoomMembers(roomId: string): Promise; - isMember(roomId: string, userId: string): Promise; - - sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise; - getMessages(roomId: string, limit?: number): Promise; - - startTyping(roomId: string, userId: string, userName: string): void; - stopTyping(roomId: string, userId: string): void; - getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[]; -} - -// ─── ALASql local store ────────────────────────────────────────────────────── - -const CROSS_TAB_EVENT = "chatbox-v2-update"; - -export class LocalChatStore implements IChatStore { - private dbName: string; - private ready = false; - private listeners = new Set(); - private typingMap = new Map(); - - constructor(applicationId: string) { - this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`; - } - - isReady(): boolean { return this.ready; } - getConnectionLabel(): string { return this.ready ? "Local" : "Connecting..."; } - - async init(): Promise { - if (this.ready) return; - alasql.options.autocommit = true; - - await alasql.promise(`CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${this.dbName}`); - await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${this.dbName}`); - await alasql.promise(`USE ${this.dbName}`); - - await alasql.promise(` - CREATE TABLE IF NOT EXISTS rooms ( - id STRING PRIMARY KEY, name STRING, description STRING, - type STRING, creatorId STRING, createdAt NUMBER, updatedAt NUMBER - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS messages ( - id STRING PRIMARY KEY, roomId STRING, authorId STRING, - authorName STRING, text STRING, timestamp NUMBER - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS members ( - roomId STRING, userId STRING, userName STRING, joinedAt NUMBER - ) - `); - this.ready = true; - - if (typeof window !== "undefined") { - window.addEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate); - } - } - - destroy(): void { - if (typeof window !== "undefined") { - window.removeEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate); - } - this.listeners.clear(); - } - - subscribe(listener: ChatStoreListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(): void { - this.listeners.forEach((fn) => fn()); - if (typeof window !== "undefined") { - try { window.dispatchEvent(new CustomEvent(CROSS_TAB_EVENT)); } catch { /* noop */ } - } - } - - private onCrossTabUpdate = () => { - this.listeners.forEach((fn) => fn()); - }; - - // ── Rooms ────────────────────────────────────────────────────────────── - - async createRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string, description = ""): Promise { - this.assert(); - const id = uid(); - const now = Date.now(); - await alasql.promise(`INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?)`, [id, name, description, type, creatorId, now, now]); - await alasql.promise(`INSERT INTO members VALUES (?, ?, ?, ?)`, [id, creatorId, creatorName, now]); - this.notify(); - return { id, name, description, type, creatorId, createdAt: now, updatedAt: now }; - } - - async getRoom(roomId: string): Promise { - this.assert(); - const rows = (await alasql.promise(`SELECT * FROM rooms WHERE id = ?`, [roomId])) as ChatRoom[]; - return rows.length > 0 ? rows[0] : null; - } - - async getRoomByName(name: string): Promise { - this.assert(); - const rows = (await alasql.promise(`SELECT * FROM rooms WHERE name = ?`, [name])) as ChatRoom[]; - return rows.length > 0 ? rows[0] : null; - } - - async getAllRooms(): Promise { - this.assert(); - return (await alasql.promise(`SELECT * FROM rooms ORDER BY updatedAt DESC`)) as ChatRoom[]; - } - - async getUserRooms(userId: string): Promise { - this.assert(); - return (await alasql.promise( - `SELECT r.* FROM rooms r JOIN members m ON r.id = m.roomId WHERE m.userId = ? ORDER BY r.updatedAt DESC`, - [userId], - )) as ChatRoom[]; - } - - async getSearchableRooms(userId: string, query: string): Promise { - this.assert(); - const q = `%${query}%`; - return (await alasql.promise( - `SELECT DISTINCT r.* FROM rooms r - WHERE r.type = 'public' - AND r.id NOT IN (SELECT roomId FROM members WHERE userId = ?) - AND (r.name LIKE ? OR r.description LIKE ?) - ORDER BY r.updatedAt DESC`, - [userId, q, q], - )) as ChatRoom[]; - } - - async ensureRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string): Promise { - let room = await this.getRoomByName(name); - if (!room) room = await this.createRoom(name, type, creatorId, creatorName); - if (!(await this.isMember(room.id, creatorId))) await this.joinRoom(room.id, creatorId, creatorName); - return room; - } - - // ── Membership ───────────────────────────────────────────────────────── - - async joinRoom(roomId: string, userId: string, userName: string): Promise { - this.assert(); - const existing = (await alasql.promise(`SELECT * FROM members WHERE roomId = ? AND userId = ?`, [roomId, userId])) as RoomMember[]; - if (existing.length > 0) return true; - await alasql.promise(`INSERT INTO members VALUES (?, ?, ?, ?)`, [roomId, userId, userName, Date.now()]); - this.notify(); - return true; - } - - async leaveRoom(roomId: string, userId: string): Promise { - this.assert(); - await alasql.promise(`DELETE FROM members WHERE roomId = ? AND userId = ?`, [roomId, userId]); - this.notify(); - return true; - } - - async getRoomMembers(roomId: string): Promise { - this.assert(); - return (await alasql.promise(`SELECT * FROM members WHERE roomId = ? ORDER BY joinedAt ASC`, [roomId])) as RoomMember[]; - } - - async isMember(roomId: string, userId: string): Promise { - this.assert(); - const rows = (await alasql.promise(`SELECT * FROM members WHERE roomId = ? AND userId = ?`, [roomId, userId])) as RoomMember[]; - return rows.length > 0; - } - - // ── Messages ─────────────────────────────────────────────────────────── - - async sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise { - this.assert(); - const msg: ChatMessage = { id: uid(), roomId, authorId, authorName, text, timestamp: Date.now() }; - await alasql.promise(`INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)`, [msg.id, msg.roomId, msg.authorId, msg.authorName, msg.text, msg.timestamp]); - await alasql.promise(`UPDATE rooms SET updatedAt = ? WHERE id = ?`, [msg.timestamp, roomId]); - this.notify(); - return msg; - } - - async getMessages(roomId: string, limit = 100): Promise { - this.assert(); - const rows = (await alasql.promise(`SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC`, [roomId])) as ChatMessage[]; - return rows.slice(-limit); - } - - // ── Typing ───────────────────────────────────────────────────────────── - - private typingKey(roomId: string, userId: string) { return `${roomId}::${userId}`; } - - startTyping(roomId: string, userId: string, userName: string): void { - this.typingMap.set(this.typingKey(roomId, userId), { userId, userName, roomId, startedAt: Date.now() }); - this.notify(); - } - - stopTyping(roomId: string, userId: string): void { - if (this.typingMap.delete(this.typingKey(roomId, userId))) { - this.notify(); - } - } - - getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { - const now = Date.now(); - const result: TypingUser[] = []; - for (const [key, entry] of this.typingMap) { - if (entry.roomId !== roomId) continue; - if (excludeUserId && entry.userId === excludeUserId) continue; - if (now - entry.startedAt > 5000) { - this.typingMap.delete(key); - continue; - } - result.push(entry); - } - return result; - } - - // ── Internal ─────────────────────────────────────────────────────────── - - private assert(): void { - if (!this.ready) throw new Error("LocalChatStore not initialized. Call init() first."); - } -} - -// ─── Yjs collaborative store ──────────────────────────────────────────────── - -export class YjsChatStore implements IChatStore { - private ydoc: Y.Doc | null = null; - private wsProvider: WebsocketProvider | null = null; - private messagesMap: Y.Map | null = null; - private roomsMap: Y.Map | null = null; - private membersMap: Y.Map | null = null; - private typingYMap: Y.Map | null = null; - private listeners = new Set(); - private ready = false; - private wsConnected = false; - - private applicationId: string; - private wsUrl: string; - - // Shared doc/provider cache so multiple components on same page reuse the connection. - private static docs = new Map(); - private static providers = new Map(); - private static refCounts = new Map(); - - constructor(applicationId: string, wsUrl: string) { - this.applicationId = applicationId; - this.wsUrl = wsUrl; - } - - isReady(): boolean { return this.ready; } - getConnectionLabel(): string { - if (!this.ready) return "Connecting..."; - return this.wsConnected ? "Online" : "Offline (local Yjs)"; - } - - async init(): Promise { - if (this.ready) return; - - const docId = `chatv2_${this.applicationId}`; - - let ydoc = YjsChatStore.docs.get(docId); - let wsProvider = YjsChatStore.providers.get(docId); - - if (!ydoc) { - ydoc = new Y.Doc(); - YjsChatStore.docs.set(docId, ydoc); - YjsChatStore.refCounts.set(docId, 1); - - wsProvider = new WebsocketProvider(this.wsUrl, docId, ydoc, { connect: true }); - YjsChatStore.providers.set(docId, wsProvider); - } else { - YjsChatStore.refCounts.set(docId, (YjsChatStore.refCounts.get(docId) || 0) + 1); - wsProvider = YjsChatStore.providers.get(docId)!; - } - - this.ydoc = ydoc; - this.wsProvider = wsProvider; - this.messagesMap = ydoc.getMap("messages"); - this.roomsMap = ydoc.getMap("rooms"); - this.membersMap = ydoc.getMap("members"); - this.typingYMap = ydoc.getMap("typing"); - - // React to any Yjs mutation → notify listeners - const onChange = () => this.notify(); - this.messagesMap.observe(onChange); - this.roomsMap.observe(onChange); - this.membersMap.observe(onChange); - this.typingYMap.observe(onChange); - - if (wsProvider) { - wsProvider.on("status", (e: { status: string }) => { - this.wsConnected = e.status === "connected"; - this.notify(); - }); - this.wsConnected = wsProvider.wsconnected; - } - - this.ready = true; - this.notify(); - } - - destroy(): void { - if (this.ydoc) { - const docId = `chatv2_${this.applicationId}`; - const count = (YjsChatStore.refCounts.get(docId) || 1) - 1; - if (count <= 0) { - YjsChatStore.providers.get(docId)?.destroy(); - YjsChatStore.providers.delete(docId); - YjsChatStore.docs.delete(docId); - YjsChatStore.refCounts.delete(docId); - } else { - YjsChatStore.refCounts.set(docId, count); - } - } - this.ydoc = null; - this.wsProvider = null; - this.messagesMap = null; - this.roomsMap = null; - this.membersMap = null; - this.typingYMap = null; - this.listeners.clear(); - this.ready = false; - } - - subscribe(listener: ChatStoreListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(): void { - this.listeners.forEach((fn) => fn()); - } - - // ── Rooms ────────────────────────────────────────────────────────────── - - async createRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string, description = ""): Promise { - this.assert(); - const id = uid(); - const now = Date.now(); - const room: ChatRoom = { id, name, description, type, creatorId, createdAt: now, updatedAt: now }; - this.roomsMap!.set(id, room); - // Also add creator as member - this.addMemberEntry(id, creatorId, creatorName, now); - return room; - } - - async getRoom(roomId: string): Promise { - this.assert(); - return (this.roomsMap!.get(roomId) as ChatRoom) ?? null; - } - - async getRoomByName(name: string): Promise { - this.assert(); - for (const room of this.roomsMap!.values()) { - if ((room as ChatRoom).name === name) return room as ChatRoom; - } - return null; - } - - async getAllRooms(): Promise { - this.assert(); - const rooms: ChatRoom[] = []; - this.roomsMap!.forEach((v) => rooms.push(v as ChatRoom)); - rooms.sort((a, b) => b.updatedAt - a.updatedAt); - return rooms; - } - - async getUserRooms(userId: string): Promise { - this.assert(); - const memberRoomIds = new Set(); - this.membersMap!.forEach((v: any, key: string) => { - if (v.userId === userId) memberRoomIds.add(v.roomId); - }); - const rooms: ChatRoom[] = []; - this.roomsMap!.forEach((v) => { - const r = v as ChatRoom; - if (memberRoomIds.has(r.id)) rooms.push(r); - }); - rooms.sort((a, b) => b.updatedAt - a.updatedAt); - return rooms; - } - - async getSearchableRooms(userId: string, query: string): Promise { - this.assert(); - const memberRoomIds = new Set(); - this.membersMap!.forEach((v: any) => { - if (v.userId === userId) memberRoomIds.add(v.roomId); - }); - const lq = query.toLowerCase(); - const rooms: ChatRoom[] = []; - this.roomsMap!.forEach((v) => { - const r = v as ChatRoom; - if (r.type !== "public") return; - if (memberRoomIds.has(r.id)) return; - if (r.name.toLowerCase().includes(lq) || r.description.toLowerCase().includes(lq)) { - rooms.push(r); - } - }); - rooms.sort((a, b) => b.updatedAt - a.updatedAt); - return rooms; - } - - async ensureRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string): Promise { - let room = await this.getRoomByName(name); - if (!room) room = await this.createRoom(name, type, creatorId, creatorName); - if (!(await this.isMember(room.id, creatorId))) await this.joinRoom(room.id, creatorId, creatorName); - return room; - } - - // ── Membership ───────────────────────────────────────────────────────── - - private memberKey(roomId: string, userId: string) { return `${roomId}::${userId}`; } - - private addMemberEntry(roomId: string, userId: string, userName: string, joinedAt: number) { - this.membersMap!.set(this.memberKey(roomId, userId), { roomId, userId, userName, joinedAt } as RoomMember); - } - - async joinRoom(roomId: string, userId: string, userName: string): Promise { - this.assert(); - const key = this.memberKey(roomId, userId); - if (this.membersMap!.has(key)) return true; - this.addMemberEntry(roomId, userId, userName, Date.now()); - return true; - } - - async leaveRoom(roomId: string, userId: string): Promise { - this.assert(); - this.membersMap!.delete(this.memberKey(roomId, userId)); - return true; - } - - async getRoomMembers(roomId: string): Promise { - this.assert(); - const members: RoomMember[] = []; - this.membersMap!.forEach((v: any) => { - if (v.roomId === roomId) members.push(v as RoomMember); - }); - members.sort((a, b) => a.joinedAt - b.joinedAt); - return members; - } - - async isMember(roomId: string, userId: string): Promise { - this.assert(); - return this.membersMap!.has(this.memberKey(roomId, userId)); - } - - // ── Messages ─────────────────────────────────────────────────────────── - - async sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise { - this.assert(); - const msg: ChatMessage = { id: uid(), roomId, authorId, authorName, text, timestamp: Date.now() }; - this.messagesMap!.set(msg.id, msg); - // Touch room updatedAt - const room = this.roomsMap!.get(roomId) as ChatRoom | undefined; - if (room) { - this.roomsMap!.set(roomId, { ...room, updatedAt: msg.timestamp }); - } - return msg; - } - - async getMessages(roomId: string, limit = 100): Promise { - this.assert(); - const msgs: ChatMessage[] = []; - this.messagesMap!.forEach((v) => { - const m = v as ChatMessage; - if (m.roomId === roomId) msgs.push(m); - }); - msgs.sort((a, b) => a.timestamp - b.timestamp); - return msgs.slice(-limit); - } - - // ── Typing ───────────────────────────────────────────────────────────── - - private typingKey(roomId: string, userId: string) { return `${roomId}::${userId}`; } - - startTyping(roomId: string, userId: string, userName: string): void { - this.typingYMap?.set(this.typingKey(roomId, userId), { userId, userName, roomId, startedAt: Date.now() } as TypingUser); - } - - stopTyping(roomId: string, userId: string): void { - this.typingYMap?.delete(this.typingKey(roomId, userId)); - } - - getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { - if (!this.typingYMap) return []; - const now = Date.now(); - const result: TypingUser[] = []; - this.typingYMap.forEach((v: any, key: string) => { - const entry = v as TypingUser; - if (entry.roomId !== roomId) return; - if (excludeUserId && entry.userId === excludeUserId) return; - if (now - entry.startedAt > 5000) { - this.typingYMap!.delete(key); - return; - } - result.push(entry); - }); - return result; - } - - // ── Internal ─────────────────────────────────────────────────────────── - - private assert(): void { - if (!this.ready) throw new Error("YjsChatStore not initialized. Call init() first."); - } -} - -// ─── Hybrid store (local + Yjs with fallback) ─────────────────────────────── - -/** - * Wraps both a LocalChatStore and a YjsChatStore. Writes go to both; - * reads prefer Yjs when the WebSocket is connected, otherwise fall back to - * local. This gives offline-capable persistence with real-time sync when - * the server is reachable. - */ -export class HybridChatStore implements IChatStore { - private local: LocalChatStore; - private yjs: YjsChatStore; - private listeners = new Set(); - private ready = false; - - constructor(applicationId: string, wsUrl: string) { - this.local = new LocalChatStore(applicationId); - this.yjs = new YjsChatStore(applicationId, wsUrl); - } - - isReady(): boolean { return this.ready; } - getConnectionLabel(): string { - if (!this.ready) return "Connecting..."; - const yjsLabel = this.yjs.getConnectionLabel(); - return `Hybrid (${yjsLabel})`; - } - - async init(): Promise { - if (this.ready) return; - // Local always succeeds; Yjs may fail (no server) but we don't block on it. - await this.local.init(); - try { await this.yjs.init(); } catch { /* yjs offline, that's fine */ } - this.ready = true; - - this.local.subscribe(() => this.notify()); - this.yjs.subscribe(() => this.notify()); - } - - destroy(): void { - this.local.destroy(); - this.yjs.destroy(); - this.listeners.clear(); - this.ready = false; - } - - subscribe(listener: ChatStoreListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(): void { this.listeners.forEach((fn) => fn()); } - - // Prefer Yjs for reads when it's ready, fallback to local. - private get reader(): IChatStore { return this.yjs.isReady() ? this.yjs : this.local; } - - // ── Rooms (write to both, read from best available) ──────────────────── - - async createRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string, description?: string): Promise { - const room = await this.local.createRoom(name, type, creatorId, creatorName, description); - if (this.yjs.isReady()) { - try { await this.yjs.createRoom(name, type, creatorId, creatorName, description); } catch { /* offline */ } - } - return room; - } - - async getRoom(roomId: string) { return this.reader.getRoom(roomId); } - async getRoomByName(name: string) { return this.reader.getRoomByName(name); } - async getAllRooms() { return this.reader.getAllRooms(); } - async getUserRooms(userId: string) { return this.reader.getUserRooms(userId); } - async getSearchableRooms(userId: string, query: string) { return this.reader.getSearchableRooms(userId, query); } - - async ensureRoom(name: string, type: "public" | "private", creatorId: string, creatorName: string): Promise { - const room = await this.local.ensureRoom(name, type, creatorId, creatorName); - if (this.yjs.isReady()) { - try { await this.yjs.ensureRoom(name, type, creatorId, creatorName); } catch { /* offline */ } - } - return room; - } - - // ── Membership (write to both) ───────────────────────────────────────── - - async joinRoom(roomId: string, userId: string, userName: string): Promise { - const ok = await this.local.joinRoom(roomId, userId, userName); - if (this.yjs.isReady()) { try { await this.yjs.joinRoom(roomId, userId, userName); } catch { /* offline */ } } - return ok; - } - - async leaveRoom(roomId: string, userId: string): Promise { - const ok = await this.local.leaveRoom(roomId, userId); - if (this.yjs.isReady()) { try { await this.yjs.leaveRoom(roomId, userId); } catch { /* offline */ } } - return ok; - } - - async getRoomMembers(roomId: string) { return this.reader.getRoomMembers(roomId); } - async isMember(roomId: string, userId: string) { return this.reader.isMember(roomId, userId); } - - // ── Messages (write to both, read from best) ────────────────────────── - - async sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise { - const msg = await this.local.sendMessage(roomId, authorId, authorName, text); - if (this.yjs.isReady()) { - try { await this.yjs.sendMessage(roomId, authorId, authorName, text); } catch { /* offline */ } - } - return msg; - } - - async getMessages(roomId: string, limit?: number) { return this.reader.getMessages(roomId, limit); } - - // ── Typing (prefer Yjs for real-time sync, fallback to local) ───────── - - startTyping(roomId: string, userId: string, userName: string): void { - if (this.yjs.isReady()) { - this.yjs.startTyping(roomId, userId, userName); - } else { - this.local.startTyping(roomId, userId, userName); - } - } - - stopTyping(roomId: string, userId: string): void { - if (this.yjs.isReady()) { - this.yjs.stopTyping(roomId, userId); - } else { - this.local.stopTyping(roomId, userId); - } - } - - getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { - return this.yjs.isReady() - ? this.yjs.getTypingUsers(roomId, excludeUserId) - : this.local.getTypingUsers(roomId, excludeUserId); - } -} - -// ─── Helpers & cache ───────────────────────────────────────────────────────── - -function uid(): string { - return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; -} - -const storeCache = new Map(); - -export function getChatStore( - applicationId: string, - mode: SyncMode = "local", - wsUrl = "ws://localhost:3005", -): IChatStore { - const key = `${applicationId}__${mode}`; - if (!storeCache.has(key)) { - let store: IChatStore; - switch (mode) { - case "collaborative": - store = new YjsChatStore(applicationId, wsUrl); - break; - case "hybrid": - store = new HybridChatStore(applicationId, wsUrl); - break; - default: - store = new LocalChatStore(applicationId); - } - storeCache.set(key, store); - } - return storeCache.get(key)!; -} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/HybridChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/HybridChatStore.ts new file mode 100644 index 000000000..7eb2078fd --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/HybridChatStore.ts @@ -0,0 +1,179 @@ +import { LocalChatStore } from "./LocalChatStore"; +import { YjsChatStore } from "./YjsChatStore"; +import type { + IChatStore, + ChatMessage, + ChatRoom, + RoomMember, + TypingUser, + ChangeType, + ChatStoreListener, +} from "./types"; +import { uid } from "./types"; + +/** + * Wraps both a LocalChatStore and a YjsChatStore. Writes go to both; + * reads prefer Yjs when the WebSocket is connected, otherwise fall back to + * local. IDs are generated once and passed to both sub-stores so the same + * logical entity keeps a consistent identity across both. + */ +export class HybridChatStore implements IChatStore { + private local: LocalChatStore; + private yjs: YjsChatStore; + private listeners = new Set(); + private ready = false; + + constructor(applicationId: string, wsUrl: string) { + this.local = new LocalChatStore(applicationId); + this.yjs = new YjsChatStore(applicationId, wsUrl); + } + + isReady(): boolean { + return this.ready; + } + + getConnectionLabel(): string { + if (!this.ready) return "Connecting..."; + const yjsLabel = this.yjs.getConnectionLabel(); + return `Hybrid (${yjsLabel})`; + } + + async init(): Promise { + if (this.ready) return; + await this.local.init(); + try { + await this.yjs.init(); + } catch { /* yjs offline, that's fine */ } + this.ready = true; + + this.local.subscribe((changes) => this.notify(changes)); + this.yjs.subscribe((changes) => this.notify(changes)); + } + + destroy(): void { + this.local.destroy(); + this.yjs.destroy(); + this.listeners.clear(); + this.ready = false; + } + + subscribe(listener: ChatStoreListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(changes: Set): void { + this.listeners.forEach((fn) => fn(changes)); + } + + private get reader(): IChatStore { + return this.yjs.isReady() ? this.yjs : this.local; + } + + // ── Rooms (write to both with shared ID, read from best available) ──── + + async createRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + description?: string, + id?: string, + ): Promise { + const sharedId = id ?? uid(); + const room = await this.local.createRoom(name, type, creatorId, creatorName, description, sharedId); + if (this.yjs.isReady()) { + try { + await this.yjs.createRoom(name, type, creatorId, creatorName, description, sharedId); + } catch { /* offline */ } + } + return room; + } + + async getRoom(roomId: string) { return this.reader.getRoom(roomId); } + async getRoomByName(name: string) { return this.reader.getRoomByName(name); } + async getAllRooms() { return this.reader.getAllRooms(); } + async getUserRooms(userId: string) { return this.reader.getUserRooms(userId); } + async getSearchableRooms(userId: string, query: string) { return this.reader.getSearchableRooms(userId, query); } + + async ensureRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + ): Promise { + const room = await this.local.ensureRoom(name, type, creatorId, creatorName); + if (this.yjs.isReady()) { + try { + await this.yjs.ensureRoom(name, type, creatorId, creatorName); + } catch { /* offline */ } + } + return room; + } + + // ── Membership (write to both) ───────────────────────────────────────── + + async joinRoom(roomId: string, userId: string, userName: string): Promise { + const ok = await this.local.joinRoom(roomId, userId, userName); + if (this.yjs.isReady()) { + try { await this.yjs.joinRoom(roomId, userId, userName); } catch { /* offline */ } + } + return ok; + } + + async leaveRoom(roomId: string, userId: string): Promise { + const ok = await this.local.leaveRoom(roomId, userId); + if (this.yjs.isReady()) { + try { await this.yjs.leaveRoom(roomId, userId); } catch { /* offline */ } + } + return ok; + } + + async getRoomMembers(roomId: string) { return this.reader.getRoomMembers(roomId); } + async isMember(roomId: string, userId: string) { return this.reader.isMember(roomId, userId); } + + // ── Messages (write to both with shared ID, read from best) ─────────── + + async sendMessage( + roomId: string, + authorId: string, + authorName: string, + text: string, + id?: string, + ): Promise { + const sharedId = id ?? uid(); + const msg = await this.local.sendMessage(roomId, authorId, authorName, text, sharedId); + if (this.yjs.isReady()) { + try { + await this.yjs.sendMessage(roomId, authorId, authorName, text, sharedId); + } catch { /* offline */ } + } + return msg; + } + + async getMessages(roomId: string, limit?: number) { return this.reader.getMessages(roomId, limit); } + + // ── Typing (prefer Yjs for real-time sync, fallback to local) ───────── + + startTyping(roomId: string, userId: string, userName: string): void { + if (this.yjs.isReady()) { + this.yjs.startTyping(roomId, userId, userName); + } else { + this.local.startTyping(roomId, userId, userName); + } + } + + stopTyping(roomId: string, userId: string): void { + if (this.yjs.isReady()) { + this.yjs.stopTyping(roomId, userId); + } else { + this.local.stopTyping(roomId, userId); + } + } + + getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { + return this.yjs.isReady() + ? this.yjs.getTypingUsers(roomId, excludeUserId) + : this.local.getTypingUsers(roomId, excludeUserId); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/LocalChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/LocalChatStore.ts new file mode 100644 index 000000000..8bf41bb38 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/LocalChatStore.ts @@ -0,0 +1,304 @@ +import alasql from "alasql"; +import type { + IChatStore, + ChatMessage, + ChatRoom, + RoomMember, + TypingUser, + ChangeType, + ChatStoreListener, +} from "./types"; +import { uid } from "./types"; + +const CROSS_TAB_EVENT = "chatbox-v2-update"; + +export class LocalChatStore implements IChatStore { + private dbName: string; + private ready = false; + private listeners = new Set(); + private typingMap = new Map(); + + constructor(applicationId: string) { + this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`; + } + + isReady(): boolean { + return this.ready; + } + + getConnectionLabel(): string { + return this.ready ? "Local" : "Connecting..."; + } + + async init(): Promise { + if (this.ready) return; + alasql.options.autocommit = true; + + await alasql.promise(`CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${this.dbName}`); + await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${this.dbName}`); + await alasql.promise(`USE ${this.dbName}`); + + await alasql.promise(` + CREATE TABLE IF NOT EXISTS rooms ( + id STRING PRIMARY KEY, name STRING, description STRING, + type STRING, creatorId STRING, createdAt NUMBER, updatedAt NUMBER + ) + `); + await alasql.promise(` + CREATE TABLE IF NOT EXISTS messages ( + id STRING PRIMARY KEY, roomId STRING, authorId STRING, + authorName STRING, text STRING, timestamp NUMBER + ) + `); + await alasql.promise(` + CREATE TABLE IF NOT EXISTS members ( + roomId STRING, userId STRING, userName STRING, joinedAt NUMBER + ) + `); + this.ready = true; + + if (typeof window !== "undefined") { + window.addEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate); + } + } + + destroy(): void { + if (typeof window !== "undefined") { + window.removeEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate); + } + this.listeners.clear(); + } + + subscribe(listener: ChatStoreListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(changes: Set): void { + this.listeners.forEach((fn) => fn(changes)); + if (typeof window !== "undefined") { + try { + window.dispatchEvent(new CustomEvent(CROSS_TAB_EVENT)); + } catch { /* noop */ } + } + } + + private onCrossTabUpdate = () => { + this.listeners.forEach((fn) => + fn(new Set(["rooms", "messages", "members"])), + ); + }; + + // ── Rooms ────────────────────────────────────────────────────────────── + + async createRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + description = "", + id?: string, + ): Promise { + this.assert(); + const roomId = id ?? uid(); + const now = Date.now(); + await alasql.promise( + `INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?)`, + [roomId, name, description, type, creatorId, now, now], + ); + await alasql.promise( + `INSERT INTO members VALUES (?, ?, ?, ?)`, + [roomId, creatorId, creatorName, now], + ); + this.notify(new Set(["rooms", "members"])); + return { id: roomId, name, description, type, creatorId, createdAt: now, updatedAt: now }; + } + + async getRoom(roomId: string): Promise { + this.assert(); + const rows = (await alasql.promise( + `SELECT * FROM rooms WHERE id = ?`, + [roomId], + )) as ChatRoom[]; + return rows.length > 0 ? rows[0] : null; + } + + async getRoomByName(name: string): Promise { + this.assert(); + const rows = (await alasql.promise( + `SELECT * FROM rooms WHERE name = ?`, + [name], + )) as ChatRoom[]; + return rows.length > 0 ? rows[0] : null; + } + + async getAllRooms(): Promise { + this.assert(); + return (await alasql.promise( + `SELECT * FROM rooms ORDER BY updatedAt DESC`, + )) as ChatRoom[]; + } + + async getUserRooms(userId: string): Promise { + this.assert(); + return (await alasql.promise( + `SELECT r.* FROM rooms r JOIN members m ON r.id = m.roomId WHERE m.userId = ? ORDER BY r.updatedAt DESC`, + [userId], + )) as ChatRoom[]; + } + + async getSearchableRooms(userId: string, query: string): Promise { + this.assert(); + const q = `%${query}%`; + return (await alasql.promise( + `SELECT DISTINCT r.* FROM rooms r + WHERE r.type = 'public' + AND r.id NOT IN (SELECT roomId FROM members WHERE userId = ?) + AND (r.name LIKE ? OR r.description LIKE ?) + ORDER BY r.updatedAt DESC`, + [userId, q, q], + )) as ChatRoom[]; + } + + async ensureRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + ): Promise { + let room = await this.getRoomByName(name); + if (!room) room = await this.createRoom(name, type, creatorId, creatorName); + if (!(await this.isMember(room.id, creatorId))) + await this.joinRoom(room.id, creatorId, creatorName); + return room; + } + + // ── Membership ───────────────────────────────────────────────────────── + + async joinRoom(roomId: string, userId: string, userName: string): Promise { + this.assert(); + const existing = (await alasql.promise( + `SELECT * FROM members WHERE roomId = ? AND userId = ?`, + [roomId, userId], + )) as RoomMember[]; + if (existing.length > 0) return true; + await alasql.promise( + `INSERT INTO members VALUES (?, ?, ?, ?)`, + [roomId, userId, userName, Date.now()], + ); + this.notify(new Set(["members"])); + return true; + } + + async leaveRoom(roomId: string, userId: string): Promise { + this.assert(); + await alasql.promise( + `DELETE FROM members WHERE roomId = ? AND userId = ?`, + [roomId, userId], + ); + this.notify(new Set(["members"])); + return true; + } + + async getRoomMembers(roomId: string): Promise { + this.assert(); + return (await alasql.promise( + `SELECT * FROM members WHERE roomId = ? ORDER BY joinedAt ASC`, + [roomId], + )) as RoomMember[]; + } + + async isMember(roomId: string, userId: string): Promise { + this.assert(); + const rows = (await alasql.promise( + `SELECT * FROM members WHERE roomId = ? AND userId = ?`, + [roomId, userId], + )) as RoomMember[]; + return rows.length > 0; + } + + // ── Messages ─────────────────────────────────────────────────────────── + + async sendMessage( + roomId: string, + authorId: string, + authorName: string, + text: string, + id?: string, + ): Promise { + this.assert(); + const msg: ChatMessage = { + id: id ?? uid(), + roomId, + authorId, + authorName, + text, + timestamp: Date.now(), + }; + await alasql.promise( + `INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)`, + [msg.id, msg.roomId, msg.authorId, msg.authorName, msg.text, msg.timestamp], + ); + await alasql.promise( + `UPDATE rooms SET updatedAt = ? WHERE id = ?`, + [msg.timestamp, roomId], + ); + this.notify(new Set(["messages", "rooms"])); + return msg; + } + + async getMessages(roomId: string, limit = 100): Promise { + this.assert(); + const rows = (await alasql.promise( + `SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC`, + [roomId], + )) as ChatMessage[]; + return rows.slice(-limit); + } + + // ── Typing ───────────────────────────────────────────────────────────── + + private typingKey(roomId: string, userId: string) { + return `${roomId}::${userId}`; + } + + startTyping(roomId: string, userId: string, userName: string): void { + this.typingMap.set(this.typingKey(roomId, userId), { + userId, + userName, + roomId, + startedAt: Date.now(), + }); + this.notify(new Set(["typing"])); + } + + stopTyping(roomId: string, userId: string): void { + if (this.typingMap.delete(this.typingKey(roomId, userId))) { + this.notify(new Set(["typing"])); + } + } + + getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { + const now = Date.now(); + const result: TypingUser[] = []; + let expired = false; + for (const [key, entry] of this.typingMap) { + if (entry.roomId !== roomId) continue; + if (excludeUserId && entry.userId === excludeUserId) continue; + if (now - entry.startedAt > 5000) { + this.typingMap.delete(key); + expired = true; + continue; + } + result.push(entry); + } + if (expired) this.notify(new Set(["typing"])); + return result; + } + + // ── Internal ─────────────────────────────────────────────────────────── + + private assert(): void { + if (!this.ready) throw new Error("LocalChatStore not initialized. Call init() first."); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/YjsChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/YjsChatStore.ts new file mode 100644 index 000000000..d3709fd11 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/YjsChatStore.ts @@ -0,0 +1,336 @@ +import * as Y from "yjs"; +import { WebsocketProvider } from "y-websocket"; +import type { + IChatStore, + ChatMessage, + ChatRoom, + RoomMember, + TypingUser, + ChangeType, + ChatStoreListener, +} from "./types"; +import { uid } from "./types"; + +export class YjsChatStore implements IChatStore { + private ydoc: Y.Doc | null = null; + private wsProvider: WebsocketProvider | null = null; + private messagesMap: Y.Map | null = null; + private roomsMap: Y.Map | null = null; + private membersMap: Y.Map | null = null; + private typingYMap: Y.Map | null = null; + private listeners = new Set(); + private ready = false; + private wsConnected = false; + + private applicationId: string; + private wsUrl: string; + + private static docs = new Map(); + private static providers = new Map(); + private static refCounts = new Map(); + + constructor(applicationId: string, wsUrl: string) { + this.applicationId = applicationId; + this.wsUrl = wsUrl; + } + + isReady(): boolean { + return this.ready; + } + + isWsConnected(): boolean { + return this.wsConnected; + } + + getConnectionLabel(): string { + if (!this.ready) return "Connecting..."; + return this.wsConnected ? "Online" : "Offline (local Yjs)"; + } + + async init(): Promise { + if (this.ready) return; + + const docId = `chatv2_${this.applicationId}`; + + let ydoc = YjsChatStore.docs.get(docId); + let wsProvider = YjsChatStore.providers.get(docId); + + if (!ydoc) { + ydoc = new Y.Doc(); + YjsChatStore.docs.set(docId, ydoc); + YjsChatStore.refCounts.set(docId, 1); + + wsProvider = new WebsocketProvider(this.wsUrl, docId, ydoc, { connect: true }); + YjsChatStore.providers.set(docId, wsProvider); + } else { + YjsChatStore.refCounts.set(docId, (YjsChatStore.refCounts.get(docId) || 0) + 1); + wsProvider = YjsChatStore.providers.get(docId)!; + } + + this.ydoc = ydoc; + this.wsProvider = wsProvider; + this.messagesMap = ydoc.getMap("messages"); + this.roomsMap = ydoc.getMap("rooms"); + this.membersMap = ydoc.getMap("members"); + this.typingYMap = ydoc.getMap("typing"); + + this.messagesMap.observe(() => this.notify(new Set(["messages"]))); + this.roomsMap.observe(() => this.notify(new Set(["rooms"]))); + this.membersMap.observe(() => this.notify(new Set(["members"]))); + this.typingYMap.observe(() => this.notify(new Set(["typing"]))); + + if (wsProvider) { + wsProvider.on("status", (e: { status: string }) => { + this.wsConnected = e.status === "connected"; + this.notify(new Set(["connection"])); + }); + this.wsConnected = wsProvider.wsconnected; + } + + this.ready = true; + this.notify(new Set(["connection"])); + } + + destroy(): void { + if (this.ydoc) { + const docId = `chatv2_${this.applicationId}`; + const count = (YjsChatStore.refCounts.get(docId) || 1) - 1; + if (count <= 0) { + YjsChatStore.providers.get(docId)?.destroy(); + YjsChatStore.providers.delete(docId); + YjsChatStore.docs.delete(docId); + YjsChatStore.refCounts.delete(docId); + } else { + YjsChatStore.refCounts.set(docId, count); + } + } + this.ydoc = null; + this.wsProvider = null; + this.messagesMap = null; + this.roomsMap = null; + this.membersMap = null; + this.typingYMap = null; + this.listeners.clear(); + this.ready = false; + } + + subscribe(listener: ChatStoreListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(changes: Set): void { + this.listeners.forEach((fn) => fn(changes)); + } + + // ── Rooms ────────────────────────────────────────────────────────────── + + async createRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + description = "", + id?: string, + ): Promise { + this.assert(); + const roomId = id ?? uid(); + const now = Date.now(); + const room: ChatRoom = { id: roomId, name, description, type, creatorId, createdAt: now, updatedAt: now }; + this.roomsMap!.set(roomId, room); + this.addMemberEntry(roomId, creatorId, creatorName, now); + return room; + } + + async getRoom(roomId: string): Promise { + this.assert(); + return (this.roomsMap!.get(roomId) as ChatRoom) ?? null; + } + + async getRoomByName(name: string): Promise { + this.assert(); + for (const room of this.roomsMap!.values()) { + if ((room as ChatRoom).name === name) return room as ChatRoom; + } + return null; + } + + async getAllRooms(): Promise { + this.assert(); + const rooms: ChatRoom[] = []; + this.roomsMap!.forEach((v) => rooms.push(v as ChatRoom)); + rooms.sort((a, b) => b.updatedAt - a.updatedAt); + return rooms; + } + + async getUserRooms(userId: string): Promise { + this.assert(); + const memberRoomIds = new Set(); + this.membersMap!.forEach((v: any) => { + if (v.userId === userId) memberRoomIds.add(v.roomId); + }); + const rooms: ChatRoom[] = []; + this.roomsMap!.forEach((v) => { + const r = v as ChatRoom; + if (memberRoomIds.has(r.id)) rooms.push(r); + }); + rooms.sort((a, b) => b.updatedAt - a.updatedAt); + return rooms; + } + + async getSearchableRooms(userId: string, query: string): Promise { + this.assert(); + const memberRoomIds = new Set(); + this.membersMap!.forEach((v: any) => { + if (v.userId === userId) memberRoomIds.add(v.roomId); + }); + const lq = query.toLowerCase(); + const rooms: ChatRoom[] = []; + this.roomsMap!.forEach((v) => { + const r = v as ChatRoom; + if (r.type !== "public") return; + if (memberRoomIds.has(r.id)) return; + if (r.name.toLowerCase().includes(lq) || r.description.toLowerCase().includes(lq)) { + rooms.push(r); + } + }); + rooms.sort((a, b) => b.updatedAt - a.updatedAt); + return rooms; + } + + async ensureRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + ): Promise { + let room = await this.getRoomByName(name); + if (!room) room = await this.createRoom(name, type, creatorId, creatorName); + if (!(await this.isMember(room.id, creatorId))) + await this.joinRoom(room.id, creatorId, creatorName); + return room; + } + + // ── Membership ───────────────────────────────────────────────────────── + + private memberKey(roomId: string, userId: string) { + return `${roomId}::${userId}`; + } + + private addMemberEntry(roomId: string, userId: string, userName: string, joinedAt: number) { + this.membersMap!.set(this.memberKey(roomId, userId), { + roomId, + userId, + userName, + joinedAt, + } as RoomMember); + } + + async joinRoom(roomId: string, userId: string, userName: string): Promise { + this.assert(); + const key = this.memberKey(roomId, userId); + if (this.membersMap!.has(key)) return true; + this.addMemberEntry(roomId, userId, userName, Date.now()); + return true; + } + + async leaveRoom(roomId: string, userId: string): Promise { + this.assert(); + this.membersMap!.delete(this.memberKey(roomId, userId)); + return true; + } + + async getRoomMembers(roomId: string): Promise { + this.assert(); + const members: RoomMember[] = []; + this.membersMap!.forEach((v: any) => { + if (v.roomId === roomId) members.push(v as RoomMember); + }); + members.sort((a, b) => a.joinedAt - b.joinedAt); + return members; + } + + async isMember(roomId: string, userId: string): Promise { + this.assert(); + return this.membersMap!.has(this.memberKey(roomId, userId)); + } + + // ── Messages ─────────────────────────────────────────────────────────── + + async sendMessage( + roomId: string, + authorId: string, + authorName: string, + text: string, + id?: string, + ): Promise { + this.assert(); + const msg: ChatMessage = { + id: id ?? uid(), + roomId, + authorId, + authorName, + text, + timestamp: Date.now(), + }; + this.messagesMap!.set(msg.id, msg); + const room = this.roomsMap!.get(roomId) as ChatRoom | undefined; + if (room) { + this.roomsMap!.set(roomId, { ...room, updatedAt: msg.timestamp }); + } + return msg; + } + + async getMessages(roomId: string, limit = 100): Promise { + this.assert(); + const msgs: ChatMessage[] = []; + this.messagesMap!.forEach((v) => { + const m = v as ChatMessage; + if (m.roomId === roomId) msgs.push(m); + }); + msgs.sort((a, b) => a.timestamp - b.timestamp); + return msgs.slice(-limit); + } + + // ── Typing ───────────────────────────────────────────────────────────── + + private typingKey(roomId: string, userId: string) { + return `${roomId}::${userId}`; + } + + startTyping(roomId: string, userId: string, userName: string): void { + this.typingYMap?.set(this.typingKey(roomId, userId), { + userId, + userName, + roomId, + startedAt: Date.now(), + } as TypingUser); + } + + stopTyping(roomId: string, userId: string): void { + this.typingYMap?.delete(this.typingKey(roomId, userId)); + } + + getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { + if (!this.typingYMap) return []; + const now = Date.now(); + const result: TypingUser[] = []; + this.typingYMap.forEach((v: any, key: string) => { + const entry = v as TypingUser; + if (entry.roomId !== roomId) return; + if (excludeUserId && entry.userId === excludeUserId) return; + if (now - entry.startedAt > 5000) { + this.typingYMap!.delete(key); + return; + } + result.push(entry); + }); + return result; + } + + // ── Internal ─────────────────────────────────────────────────────────── + + private assert(): void { + if (!this.ready) throw new Error("YjsChatStore not initialized. Call init() first."); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts new file mode 100644 index 000000000..f8db89e31 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts @@ -0,0 +1,209 @@ +import styled from "styled-components"; +import type { TextStyleType, AnimationStyleType } from "comps/controls/styleControlConstants"; + +export const Wrapper = styled.div<{ $style: TextStyleType; $anim: AnimationStyleType }>` + height: 100%; + display: flex; + overflow: hidden; + border-radius: ${(p) => p.$style.radius || "8px"}; + border: ${(p) => p.$style.borderWidth || "1px"} solid ${(p) => p.$style.border || "#e0e0e0"}; + background: ${(p) => p.$style.background || "#fff"}; + font-family: ${(p) => p.$style.fontFamily || "inherit"}; + ${(p) => p.$anim} +`; + +export const RoomPanelContainer = styled.div<{ $width: string }>` + width: ${(p) => p.$width}; + min-width: 160px; + border-right: 1px solid #eee; + display: flex; + flex-direction: column; + background: #fafbfc; +`; + +export const RoomPanelHeader = styled.div` + padding: 12px; + font-weight: 600; + font-size: 13px; + color: #555; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const RoomListContainer = styled.div` + flex: 1; + overflow-y: auto; + padding: 8px; +`; + +export const RoomItemStyled = styled.div<{ $active: boolean }>` + padding: 8px 10px; + margin-bottom: 4px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 6px; + background: ${(p) => (p.$active ? "#1890ff" : "#fff")}; + color: ${(p) => (p.$active ? "#fff" : "#333")}; + border: 1px solid ${(p) => (p.$active ? "#1890ff" : "#f0f0f0")}; + + &:hover { + background: ${(p) => (p.$active ? "#1890ff" : "#f5f5f5")}; + } +`; + +export const SearchResultBadge = styled.span` + font-size: 10px; + background: #e6f7ff; + color: #1890ff; + padding: 1px 6px; + border-radius: 8px; + font-weight: 500; + margin-left: auto; +`; + +export const ChatPanelContainer = styled.div` + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +`; + +export const ChatHeaderBar = styled.div` + padding: 12px 16px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const MessagesArea = styled.div` + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const Bubble = styled.div<{ $own: boolean }>` + max-width: 70%; + padding: 10px 14px; + border-radius: 16px; + align-self: ${(p) => (p.$own ? "flex-end" : "flex-start")}; + background: ${(p) => (p.$own ? "#1890ff" : "#f0f0f0")}; + color: ${(p) => (p.$own ? "#fff" : "#333")}; + font-size: 14px; + word-break: break-word; +`; + +export const BubbleMeta = styled.div<{ $own: boolean }>` + font-size: 11px; + opacity: 0.7; + margin-bottom: 2px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const BubbleTime = styled.div<{ $own: boolean }>` + font-size: 10px; + opacity: 0.6; + margin-top: 4px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const InputBarContainer = styled.div` + padding: 12px 16px; + border-top: 1px solid #eee; + display: flex; + gap: 8px; + align-items: flex-end; +`; + +export const StyledTextArea = styled.textarea` + flex: 1; + padding: 8px 14px; + border: 1px solid #d9d9d9; + border-radius: 18px; + resize: none; + min-height: 36px; + max-height: 96px; + font-size: 14px; + outline: none; + font-family: inherit; + line-height: 1.4; + &:focus { + border-color: #1890ff; + } +`; + +export const EmptyChat = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #999; + gap: 4px; +`; + +export const TypingIndicatorWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + align-self: flex-start; +`; + +export const TypingDots = styled.span` + display: inline-flex; + align-items: center; + gap: 3px; + background: #e8e8e8; + border-radius: 12px; + padding: 8px 12px; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: #999; + animation: typingBounce 1.4s infinite ease-in-out both; + } + + span:nth-child(1) { animation-delay: 0s; } + span:nth-child(2) { animation-delay: 0.2s; } + span:nth-child(3) { animation-delay: 0.4s; } + + @keyframes typingBounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } + } +`; + +export const TypingLabel = styled.span` + font-size: 12px; + color: #999; + font-style: italic; +`; + +export const ConnectionBanner = styled.div<{ $status: "online" | "offline" | "connecting" }>` + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: ${(p) => + p.$status === "online" ? "#52c41a" : p.$status === "offline" ? "#fa541c" : "#999"}; +`; + +export const ConnectionDot = styled.span<{ $status: "online" | "offline" | "connecting" }>` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${(p) => + p.$status === "online" ? "#52c41a" : p.$status === "offline" ? "#fa541c" : "#d9d9d9"}; +`; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts index 04fd172f2..1ab650f35 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -1,13 +1,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { - type IChatStore, - type ChatMessage, - type ChatRoom, - type RoomMember, - type TypingUser, - type SyncMode, - getChatStore, -} from "./chatDataStore"; +import type { + IChatStore, + ChatMessage, + ChatRoom, + RoomMember, + TypingUser, + SyncMode, + ChangeType, +} from "./store"; +import { getChatStore, releaseChatStore } from "./store"; export interface UseChatStoreConfig { applicationId: string; @@ -39,6 +40,8 @@ export interface UseChatStoreReturn { stopTyping: () => void; } +const TYPING_POLL_INTERVAL = 1500; + export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { const { applicationId, defaultRoom, userId, userName, mode, wsUrl } = config; @@ -56,7 +59,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { const activeRoomIdRef = useRef(null); const typingPollRef = useRef | null>(null); - // ── Refresh helpers ──────────────────────────────────────────────────── + // ── Granular refresh helpers ────────────────────────────────────────── const refreshRooms = useCallback(async () => { const store = storeRef.current; @@ -91,15 +94,53 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { const store = storeRef.current; const roomId = activeRoomIdRef.current; if (!store || !roomId) return; - setTypingUsers(store.getTypingUsers(roomId, userId)); + const users = store.getTypingUsers(roomId, userId); + setTypingUsers(users); + return users; }, [userId]); - const refreshAll = useCallback(async () => { - await Promise.all([refreshRooms(), refreshMessages(), refreshMembers()]); - refreshTyping(); + const refreshConnection = useCallback(() => { const store = storeRef.current; if (store) setConnectionLabel(store.getConnectionLabel()); - }, [refreshRooms, refreshMessages, refreshMembers, refreshTyping]); + }, []); + + // ── Smart typing poll: only runs when someone is typing ─────────────── + + const startTypingPoll = useCallback(() => { + if (typingPollRef.current) return; + typingPollRef.current = setInterval(() => { + const users = refreshTyping(); + if (!users || users.length === 0) { + if (typingPollRef.current) { + clearInterval(typingPollRef.current); + typingPollRef.current = null; + } + } + }, TYPING_POLL_INTERVAL); + }, [refreshTyping]); + + const stopTypingPoll = useCallback(() => { + if (typingPollRef.current) { + clearInterval(typingPollRef.current); + typingPollRef.current = null; + } + }, []); + + // ── Handle granular store changes ───────────────────────────────────── + + const handleStoreChange = useCallback( + (changes: Set) => { + if (changes.has("rooms")) refreshRooms(); + if (changes.has("messages")) refreshMessages(); + if (changes.has("members")) refreshMembers(); + if (changes.has("connection")) refreshConnection(); + if (changes.has("typing")) { + const users = refreshTyping(); + if (users && users.length > 0) startTypingPoll(); + } + }, + [refreshRooms, refreshMessages, refreshMembers, refreshConnection, refreshTyping, startTypingPoll], + ); // ── Initialization ───────────────────────────────────────────────────── @@ -134,25 +175,22 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { setConnectionLabel(store.getConnectionLabel()); setReady(true); } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : "Failed to initialize chat store"); + if (!cancelled) + setError(e instanceof Error ? e.message : "Failed to initialize chat store"); } })(); - const unsub = store.subscribe(() => { - if (!cancelled) refreshAll(); + const unsub = store.subscribe((changes) => { + if (!cancelled) handleStoreChange(changes); }); - // Poll typing state every 1.5s to auto-expire stale entries - typingPollRef.current = setInterval(() => { - if (!cancelled) refreshTyping(); - }, 1500); - return () => { cancelled = true; unsub(); - if (typingPollRef.current) clearInterval(typingPollRef.current); + stopTypingPoll(); + releaseChatStore(applicationId, mode); }; - }, [applicationId, userId, userName, defaultRoom, mode, wsUrl, refreshAll, refreshTyping]); + }, [applicationId, userId, userName, defaultRoom, mode, wsUrl, handleStoreChange, stopTypingPoll]); // ── Actions ──────────────────────────────────────────────────────────── @@ -164,35 +202,37 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { try { await store.sendMessage(roomId, userId, userName, text.trim()); return true; - } catch { return false; } + } catch { + return false; + } }, [userId, userName], ); - const switchRoom = useCallback( - async (roomId: string) => { - const store = storeRef.current; - if (!store) return; - const room = await store.getRoom(roomId); - if (!room) return; - activeRoomIdRef.current = room.id; - setCurrentRoom(room); - const [msgs, members] = await Promise.all([ - store.getMessages(room.id), - store.getRoomMembers(room.id), - ]); - setMessages(msgs); - setCurrentRoomMembers(members); - }, - [], - ); + const switchRoom = useCallback(async (roomId: string) => { + const store = storeRef.current; + if (!store) return; + const room = await store.getRoom(roomId); + if (!room) return; + activeRoomIdRef.current = room.id; + setCurrentRoom(room); + const [msgs, members] = await Promise.all([ + store.getMessages(room.id), + store.getRoomMembers(room.id), + ]); + setMessages(msgs); + setCurrentRoomMembers(members); + }, []); const createRoom = useCallback( async (name: string, type: "public" | "private", description?: string): Promise => { const store = storeRef.current; if (!store) return null; - try { return await store.createRoom(name, type, userId, userName, description); } - catch { return null; } + try { + return await store.createRoom(name, type, userId, userName, description); + } catch { + return null; + } }, [userId, userName], ); @@ -205,7 +245,9 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { const ok = await store.joinRoom(roomId, userId, userName); if (ok) await switchRoom(roomId); return ok; - } catch { return false; } + } catch { + return false; + } }, [userId, userName, switchRoom], ); @@ -228,7 +270,9 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { } } return ok; - } catch { return false; } + } catch { + return false; + } }, [userId, switchRoom], ); @@ -237,8 +281,11 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { async (query: string): Promise => { const store = storeRef.current; if (!store || !query.trim()) return []; - try { return await store.getSearchableRooms(userId, query.trim()); } - catch { return []; } + try { + return await store.getSearchableRooms(userId, query.trim()); + } catch { + return []; + } }, [userId], ); diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index fb30d587b..ef9cba1e9 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -974,7 +974,7 @@ export var uiCompMap: Registry = { isContainer: true, }, - chatBoxV2: { + chatBoxV: { name: "Chat Box V2", enName: "Chat Box V2", description: "Chat Box with rooms, messaging, and local persistence", diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index 4660fc671..35ffab02e 100644 --- a/client/packages/lowcoder/src/comps/uiCompRegistry.ts +++ b/client/packages/lowcoder/src/comps/uiCompRegistry.ts @@ -145,7 +145,7 @@ export type UICompType = | "chat" //Added By Kamal Qureshi | "chatBox" //Added By Kamal Qureshi | "chatController" - | "chatBoxV2" + | "chatBoxV" | "autocomplete" //Added By Mousheng | "colorPicker" //Added By Mousheng | "floatingButton" //Added By Mousheng From 6a0ff4725a4efb26f114a612a882e9f340e75bb8 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 5 Mar 2026 20:02:39 +0500 Subject: [PATCH 21/34] refactor chatbox to multiple files --- .../components/ChatBoxView.tsx | 147 +++++++++++++ .../components/CreateRoomModal.tsx | 87 ++++++++ .../components/InputBar.tsx | 101 +++++++++ .../components/MessageList.tsx | 75 +++++++ .../components/RoomPanel.tsx | 195 ++++++++++++++++++ .../comps/chatBoxComponentv2/store/index.ts | 83 ++++++++ .../comps/chatBoxComponentv2/store/types.ts | 88 ++++++++ .../src/pages/editor/editorConstants.tsx | 2 +- 8 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx new file mode 100644 index 000000000..f6da4b1e0 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -0,0 +1,147 @@ +import React, { useCallback, useState } from "react"; +import { UserOutlined } from "@ant-design/icons"; +import type { SyncMode } from "../store"; +import { useChatStore } from "../useChatStore"; +import { Wrapper, ChatPanelContainer, ChatHeaderBar, ConnectionBanner, ConnectionDot } from "../styles"; +import { RoomPanel } from "./RoomPanel"; +import { MessageList } from "./MessageList"; +import { InputBar } from "./InputBar"; +import { CreateRoomModal } from "./CreateRoomModal"; + +export interface ChatBoxViewProps { + chatName: { value: string }; + userId: { value: string }; + userName: { value: string }; + applicationId: { value: string }; + defaultRoom: string; + mode: string; + wsUrl: string; + allowRoomCreation: boolean; + allowRoomSearch: boolean; + showRoomPanel: boolean; + roomPanelWidth: string; + style: any; + animationStyle: any; + onEvent: (event: string) => any; + [key: string]: any; +} + +function connectionStatus(ready: boolean, label: string): "online" | "offline" | "connecting" { + if (!ready) return "connecting"; + if (label.includes("Online") || label === "Local") return "online"; + return "offline"; +} + +export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { + const { + chatName, + userId, + userName, + applicationId, + defaultRoom, + mode, + wsUrl, + allowRoomCreation, + allowRoomSearch, + showRoomPanel, + roomPanelWidth, + style, + animationStyle, + onEvent, + } = props; + + const chat = useChatStore({ + applicationId: applicationId.value || "lowcoder_app", + defaultRoom: defaultRoom || "general", + userId: userId.value || "user_1", + userName: userName.value || "User", + mode: (mode as SyncMode) || "local", + wsUrl: wsUrl || "ws://localhost:3005", + }); + + const [createModalOpen, setCreateModalOpen] = useState(false); + + const handleLeaveRoom = useCallback( + async (roomId: string) => { + const ok = await chat.leaveRoom(roomId); + if (ok) onEvent("roomLeft"); + }, + [chat.leaveRoom, onEvent], + ); + + const handleJoinRoom = useCallback( + async (roomId: string) => { + const ok = await chat.joinRoom(roomId); + if (ok) onEvent("roomJoined"); + }, + [chat.joinRoom, onEvent], + ); + + const status = connectionStatus(chat.ready, chat.connectionLabel); + + return ( + + {showRoomPanel && ( + setCreateModalOpen(true)} + /> + )} + + + +
+
{chatName.value}
+
+ {chat.currentRoom?.name || "No room selected"} + {chat.currentRoomMembers.length > 0 && ( + + + {chat.currentRoomMembers.length} + + )} +
+
+ + + {chat.ready ? chat.connectionLabel : chat.error || "Connecting..."} + +
+ + + + onEvent("messageSent")} + /> +
+ + setCreateModalOpen(false)} + onCreateRoom={chat.createRoom} + onRoomCreatedEvent={() => onEvent("roomJoined")} + /> +
+ ); +}); + +ChatBoxView.displayName = "ChatBoxV2View"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx new file mode 100644 index 000000000..c644af802 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx @@ -0,0 +1,87 @@ +import React, { useCallback } from "react"; +import { Modal, Form, Input, Radio, Button, Space } from "antd"; +import { PlusOutlined, GlobalOutlined, LockOutlined } from "@ant-design/icons"; +import type { ChatRoom } from "../store"; + +export interface CreateRoomModalProps { + open: boolean; + onClose: () => void; + onCreateRoom: (name: string, type: "public" | "private", description?: string) => Promise; + onRoomCreatedEvent: () => void; +} + +export const CreateRoomModal = React.memo((props: CreateRoomModalProps) => { + const { open, onClose, onCreateRoom, onRoomCreatedEvent } = props; + const [form] = Form.useForm(); + + const handleFinish = useCallback( + async (values: { roomName: string; roomType: "public" | "private"; description?: string }) => { + const room = await onCreateRoom(values.roomName.trim(), values.roomType, values.description); + if (room) { + form.resetFields(); + onClose(); + onRoomCreatedEvent(); + } + }, + [onCreateRoom, form, onClose, onRoomCreatedEvent], + ); + + const handleCancel = useCallback(() => { + onClose(); + form.resetFields(); + }, [onClose, form]); + + return ( + +
+ + + + + + + + + + Public + + + Private + + + + + + + + + +
+
+ ); +}); + +CreateRoomModal.displayName = "CreateRoomModal"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx new file mode 100644 index 000000000..6d0cd07c7 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx @@ -0,0 +1,101 @@ +import React, { useCallback, useRef, useState } from "react"; +import { Button } from "antd"; +import { SendOutlined } from "@ant-design/icons"; +import type { ChatRoom } from "../store"; +import { InputBarContainer, StyledTextArea } from "../styles"; + +export interface InputBarProps { + ready: boolean; + currentRoom: ChatRoom | null; + onSend: (text: string) => Promise; + onStartTyping: () => void; + onStopTyping: () => void; + onMessageSentEvent: () => void; +} + +export const InputBar = React.memo((props: InputBarProps) => { + const { ready, currentRoom, onSend, onStartTyping, onStopTyping, onMessageSentEvent } = props; + const [draft, setDraft] = useState(""); + const typingTimeoutRef = useRef | null>(null); + const isTypingRef = useRef(false); + + const clearTypingTimeout = useCallback(() => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + }, []); + + const handleStopTyping = useCallback(() => { + clearTypingTimeout(); + if (isTypingRef.current) { + isTypingRef.current = false; + onStopTyping(); + } + }, [onStopTyping, clearTypingTimeout]); + + const handleSend = useCallback(async () => { + if (!draft.trim()) return; + handleStopTyping(); + const ok = await onSend(draft); + if (ok) { + setDraft(""); + onMessageSentEvent(); + } + }, [draft, onSend, onMessageSentEvent, handleStopTyping]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setDraft(value); + + if (!value.trim()) { + handleStopTyping(); + return; + } + + if (!isTypingRef.current) { + isTypingRef.current = true; + onStartTyping(); + } + + clearTypingTimeout(); + typingTimeoutRef.current = setTimeout(() => { + handleStopTyping(); + }, 2000); + }, + [onStartTyping, handleStopTyping, clearTypingTimeout], + ); + + return ( + + + +
+ )} + + + {roomListItems.length === 0 && !isSearchMode && ready && ( +
+ No rooms yet. Create or search for one. +
+ )} + + {roomListItems.map((room) => { + const isActive = currentRoomId === room.id; + const isSearch = isSearchMode; + + return ( + { + if (isSearch) { + handleJoinAndClear(room.id); + } else if (!isActive) { + onSwitchRoom(room.id); + } + }} + title={isSearch ? `Join "${room.name}"` : room.name} + > + {room.type === "public" ? ( + + ) : ( + + )} + + {room.name} + + {isSearch && Join} + {isActive && !isSearch && ( + { + e?.stopPropagation(); + onLeaveRoom(room.id); + }} + onCancel={(e) => e?.stopPropagation()} + okText="Leave" + cancelText="Cancel" + okButtonProps={{ danger: true }} + > + e.stopPropagation()} + style={{ fontSize: 12, opacity: 0.7 }} + /> + + )} + + ); + })} +
+ + ); +}); + +RoomPanel.displayName = "RoomPanel"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts new file mode 100644 index 000000000..8cfd27f35 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts @@ -0,0 +1,83 @@ +export type { + ChatMessage, + ChatRoom, + RoomMember, + TypingUser, + ChangeType, + ChatStoreListener, + SyncMode, + IChatStore, +} from "./types"; +export { uid } from "./types"; +export { LocalChatStore } from "./LocalChatStore"; +export { YjsChatStore } from "./YjsChatStore"; +export { HybridChatStore } from "./HybridChatStore"; + +import type { IChatStore, SyncMode } from "./types"; +import { LocalChatStore } from "./LocalChatStore"; +import { YjsChatStore } from "./YjsChatStore"; +import { HybridChatStore } from "./HybridChatStore"; + +// ─── Factory with reference-counted cache ──────────────────────────────────── + +interface CacheEntry { + store: IChatStore; + refCount: number; +} + +const storeCache = new Map(); + +function cacheKey(applicationId: string, mode: SyncMode): string { + return `${applicationId}__${mode}`; +} + +/** + * Returns (or creates) a store for the given application/mode pair and + * increments its reference count. Callers MUST call `releaseChatStore` + * when they no longer need the store. + */ +export function getChatStore( + applicationId: string, + mode: SyncMode = "local", + wsUrl = "ws://localhost:3005", +): IChatStore { + const key = cacheKey(applicationId, mode); + const entry = storeCache.get(key); + if (entry) { + entry.refCount++; + return entry.store; + } + + let store: IChatStore; + switch (mode) { + case "collaborative": + store = new YjsChatStore(applicationId, wsUrl); + break; + case "hybrid": + store = new HybridChatStore(applicationId, wsUrl); + break; + default: + store = new LocalChatStore(applicationId); + } + storeCache.set(key, { store, refCount: 1 }); + return store; +} + +/** + * Decrements the reference count for the given store key. When the count + * reaches zero the store is destroyed and evicted from the cache. + */ +export function releaseChatStore( + applicationId: string, + mode: SyncMode = "local", +): void { + const key = cacheKey(applicationId, mode); + const entry = storeCache.get(key); + if (!entry) return; + + entry.refCount--; + if (entry.refCount <= 0) { + entry.store.destroy(); + storeCache.delete(key); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts new file mode 100644 index 000000000..55e8a3103 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts @@ -0,0 +1,88 @@ +export interface ChatMessage { + id: string; + roomId: string; + authorId: string; + authorName: string; + text: string; + timestamp: number; +} + +export interface ChatRoom { + id: string; + name: string; + description: string; + type: "public" | "private"; + creatorId: string; + createdAt: number; + updatedAt: number; +} + +export interface RoomMember { + roomId: string; + userId: string; + userName: string; + joinedAt: number; +} + +export interface TypingUser { + userId: string; + userName: string; + roomId: string; + startedAt: number; +} + +export type ChangeType = "rooms" | "messages" | "members" | "typing" | "connection"; + +export type ChatStoreListener = (changes: Set) => void; + +export type SyncMode = "local" | "collaborative" | "hybrid"; + +export interface IChatStore { + init(): Promise; + destroy(): void; + subscribe(listener: ChatStoreListener): () => void; + isReady(): boolean; + getConnectionLabel(): string; + + createRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + description?: string, + id?: string, + ): Promise; + getRoom(roomId: string): Promise; + getRoomByName(name: string): Promise; + getAllRooms(): Promise; + getUserRooms(userId: string): Promise; + getSearchableRooms(userId: string, query: string): Promise; + ensureRoom( + name: string, + type: "public" | "private", + creatorId: string, + creatorName: string, + ): Promise; + + joinRoom(roomId: string, userId: string, userName: string): Promise; + leaveRoom(roomId: string, userId: string): Promise; + getRoomMembers(roomId: string): Promise; + isMember(roomId: string, userId: string): Promise; + + sendMessage( + roomId: string, + authorId: string, + authorName: string, + text: string, + id?: string, + ): Promise; + getMessages(roomId: string, limit?: number): Promise; + + startTyping(roomId: string, userId: string, userName: string): void; + stopTyping(roomId: string, userId: string): void; + getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[]; +} + +export function uid(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index 87ae7c984..b0bdebf15 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -309,5 +309,5 @@ export const CompStateIcon: { chat: , chatBox: , chatController: , - chatBoxV2: , + chatBoxV: , } as const; From 0a4fe35a3f190f69ba8ba9528ec7384796f80017 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 5 Mar 2026 22:56:28 +0500 Subject: [PATCH 22/34] remove duplication of modes --- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 21 +- .../components/ChatBoxView.tsx | 4 - .../store/{YjsChatStore.ts => ChatStore.ts} | 279 +++++++++++----- .../store/HybridChatStore.ts | 179 ----------- .../store/LocalChatStore.ts | 304 ------------------ .../comps/chatBoxComponentv2/store/index.ts | 58 +--- .../comps/chatBoxComponentv2/store/types.ts | 48 --- .../comps/chatBoxComponentv2/useChatStore.ts | 16 +- 8 files changed, 228 insertions(+), 681 deletions(-) rename client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/{YjsChatStore.ts => ChatStore.ts} (54%) delete mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/HybridChatStore.ts delete mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/LocalChatStore.ts diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index 34981603f..150e52cec 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -6,7 +6,6 @@ import { withMethodExposing } from "../../generators/withMethodExposing"; import { stringExposingStateControl } from "comps/controls/codeStateControl"; import { BoolControl } from "comps/controls/boolControl"; import { StringControl } from "comps/controls/codeControl"; -import { dropdownControl } from "comps/controls/dropdownControl"; import { AutoHeightControl } from "comps/controls/autoHeightControl"; import { eventHandlerControl } from "comps/controls/eventHandlerControl"; import { styleControl } from "comps/controls/styleControl"; @@ -34,15 +33,6 @@ const childrenMap = { userName: stringExposingStateControl("userName", "User"), applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), defaultRoom: withDefault(StringControl, "general"), - - mode: dropdownControl( - [ - { label: "Local (Browser Storage)", value: "local" }, - { label: "Collaborative (WebSocket)", value: "collaborative" }, - { label: "Hybrid (Local + WebSocket)", value: "hybrid" }, - ], - "local", - ), wsUrl: withDefault(StringControl, "ws://localhost:3005"), allowRoomCreation: withDefault(BoolControl, true), @@ -70,15 +60,10 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => { {children.userName.propertyView({ label: "User Name", tooltip: "Current user's display name" })} {children.applicationId.propertyView({ label: "Application ID", tooltip: "Scopes rooms to this application" })} {children.defaultRoom.propertyView({ label: "Default Room", tooltip: "Room to join on load" })} - {children.mode.propertyView({ - label: "Sync Mode", - tooltip: "Local: browser-only storage. Collaborative: real-time via WebSocket. Hybrid: both with offline fallback.", + {children.wsUrl.propertyView({ + label: "WebSocket URL", + tooltip: "Yjs WebSocket server URL for real-time sync (e.g. ws://localhost:3005)", })} - {children.mode.getView() !== "local" && - children.wsUrl.propertyView({ - label: "WebSocket URL", - tooltip: "Yjs WebSocket server URL (e.g. ws://localhost:3005)", - })}
diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx index f6da4b1e0..71bb5749d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useState } from "react"; import { UserOutlined } from "@ant-design/icons"; -import type { SyncMode } from "../store"; import { useChatStore } from "../useChatStore"; import { Wrapper, ChatPanelContainer, ChatHeaderBar, ConnectionBanner, ConnectionDot } from "../styles"; import { RoomPanel } from "./RoomPanel"; @@ -14,7 +13,6 @@ export interface ChatBoxViewProps { userName: { value: string }; applicationId: { value: string }; defaultRoom: string; - mode: string; wsUrl: string; allowRoomCreation: boolean; allowRoomSearch: boolean; @@ -39,7 +37,6 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { userName, applicationId, defaultRoom, - mode, wsUrl, allowRoomCreation, allowRoomSearch, @@ -55,7 +52,6 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { defaultRoom: defaultRoom || "general", userId: userId.value || "user_1", userName: userName.value || "User", - mode: (mode as SyncMode) || "local", wsUrl: wsUrl || "ws://localhost:3005", }); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/YjsChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts similarity index 54% rename from client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/YjsChatStore.ts rename to client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts index d3709fd11..6b4601068 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/YjsChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts @@ -1,7 +1,7 @@ import * as Y from "yjs"; import { WebsocketProvider } from "y-websocket"; +import alasql from "alasql"; import type { - IChatStore, ChatMessage, ChatRoom, RoomMember, @@ -11,17 +11,30 @@ import type { } from "./types"; import { uid } from "./types"; -export class YjsChatStore implements IChatStore { +const PERSIST_DEBOUNCE_MS = 500; + +/** + * Unified chat store backed by YJS (real-time CRDT sync) and ALAsql + * (browser-local persistence). On init the ALAsql data seeds the YJS + * doc so state survives page reloads even without a server. YJS map + * observers write changes back to ALAsql automatically. + */ +export class ChatStore { private ydoc: Y.Doc | null = null; private wsProvider: WebsocketProvider | null = null; private messagesMap: Y.Map | null = null; private roomsMap: Y.Map | null = null; private membersMap: Y.Map | null = null; private typingYMap: Y.Map | null = null; + private listeners = new Set(); private ready = false; private wsConnected = false; + private dbName: string; + private dbReady = false; + private persistTimer: ReturnType | null = null; + private applicationId: string; private wsUrl: string; @@ -32,78 +45,103 @@ export class YjsChatStore implements IChatStore { constructor(applicationId: string, wsUrl: string) { this.applicationId = applicationId; this.wsUrl = wsUrl; + this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`; } isReady(): boolean { return this.ready; } - isWsConnected(): boolean { - return this.wsConnected; - } - getConnectionLabel(): string { if (!this.ready) return "Connecting..."; - return this.wsConnected ? "Online" : "Offline (local Yjs)"; + return this.wsConnected ? "Online" : "Offline (local)"; } async init(): Promise { if (this.ready) return; - const docId = `chatv2_${this.applicationId}`; + await this.initDb(); - let ydoc = YjsChatStore.docs.get(docId); - let wsProvider = YjsChatStore.providers.get(docId); + const docId = `chatv2_${this.applicationId}`; + let ydoc = ChatStore.docs.get(docId); + let isNewDoc = false; if (!ydoc) { ydoc = new Y.Doc(); - YjsChatStore.docs.set(docId, ydoc); - YjsChatStore.refCounts.set(docId, 1); - - wsProvider = new WebsocketProvider(this.wsUrl, docId, ydoc, { connect: true }); - YjsChatStore.providers.set(docId, wsProvider); + ChatStore.docs.set(docId, ydoc); + ChatStore.refCounts.set(docId, 1); + isNewDoc = true; } else { - YjsChatStore.refCounts.set(docId, (YjsChatStore.refCounts.get(docId) || 0) + 1); - wsProvider = YjsChatStore.providers.get(docId)!; + ChatStore.refCounts.set( + docId, + (ChatStore.refCounts.get(docId) || 0) + 1, + ); } this.ydoc = ydoc; - this.wsProvider = wsProvider; this.messagesMap = ydoc.getMap("messages"); this.roomsMap = ydoc.getMap("rooms"); this.membersMap = ydoc.getMap("members"); this.typingYMap = ydoc.getMap("typing"); - this.messagesMap.observe(() => this.notify(new Set(["messages"]))); - this.roomsMap.observe(() => this.notify(new Set(["rooms"]))); - this.membersMap.observe(() => this.notify(new Set(["members"]))); - this.typingYMap.observe(() => this.notify(new Set(["typing"]))); + if (isNewDoc) { + await this.hydrateFromDb(); + } - if (wsProvider) { - wsProvider.on("status", (e: { status: string }) => { - this.wsConnected = e.status === "connected"; - this.notify(new Set(["connection"])); + let wsProvider = ChatStore.providers.get(docId); + if (!wsProvider) { + wsProvider = new WebsocketProvider(this.wsUrl, docId, ydoc, { + connect: true, }); - this.wsConnected = wsProvider.wsconnected; + ChatStore.providers.set(docId, wsProvider); } + this.wsProvider = wsProvider; + + this.messagesMap.observe(() => { + this.schedulePersist(); + this.notify(new Set(["messages"])); + }); + this.roomsMap.observe(() => { + this.schedulePersist(); + this.notify(new Set(["rooms"])); + }); + this.membersMap.observe(() => { + this.schedulePersist(); + this.notify(new Set(["members"])); + }); + this.typingYMap.observe(() => this.notify(new Set(["typing"]))); + + wsProvider.on("status", (e: { status: string }) => { + this.wsConnected = e.status === "connected"; + this.notify(new Set(["connection"])); + }); + this.wsConnected = wsProvider.wsconnected; this.ready = true; this.notify(new Set(["connection"])); } destroy(): void { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + + this.persistToDb(); + if (this.ydoc) { const docId = `chatv2_${this.applicationId}`; - const count = (YjsChatStore.refCounts.get(docId) || 1) - 1; + const count = (ChatStore.refCounts.get(docId) || 1) - 1; if (count <= 0) { - YjsChatStore.providers.get(docId)?.destroy(); - YjsChatStore.providers.delete(docId); - YjsChatStore.docs.delete(docId); - YjsChatStore.refCounts.delete(docId); + ChatStore.providers.get(docId)?.destroy(); + ChatStore.providers.delete(docId); + ChatStore.docs.delete(docId); + ChatStore.refCounts.delete(docId); } else { - YjsChatStore.refCounts.set(docId, count); + ChatStore.refCounts.set(docId, count); } } + this.ydoc = null; this.wsProvider = null; this.messagesMap = null; @@ -123,6 +161,101 @@ export class YjsChatStore implements IChatStore { this.listeners.forEach((fn) => fn(changes)); } + // ── ALAsql persistence ──────────────────────────────────────────────── + + private async initDb(): Promise { + alasql.options.autocommit = true; + await alasql.promise( + `CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${this.dbName}`, + ); + await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${this.dbName}`); + await alasql.promise(`USE ${this.dbName}`); + + await alasql.promise(` + CREATE TABLE IF NOT EXISTS rooms ( + id STRING PRIMARY KEY, name STRING, description STRING, + type STRING, creatorId STRING, createdAt NUMBER, updatedAt NUMBER + ) + `); + await alasql.promise(` + CREATE TABLE IF NOT EXISTS messages ( + id STRING PRIMARY KEY, roomId STRING, authorId STRING, + authorName STRING, text STRING, timestamp NUMBER + ) + `); + await alasql.promise(` + CREATE TABLE IF NOT EXISTS members ( + roomId STRING, userId STRING, userName STRING, joinedAt NUMBER + ) + `); + this.dbReady = true; + } + + private async hydrateFromDb(): Promise { + if (!this.dbReady) return; + + const rooms = (await alasql.promise( + `SELECT * FROM rooms`, + )) as ChatRoom[]; + for (const r of rooms) { + if (!this.roomsMap!.has(r.id)) this.roomsMap!.set(r.id, r); + } + + const messages = (await alasql.promise( + `SELECT * FROM messages`, + )) as ChatMessage[]; + for (const m of messages) { + if (!this.messagesMap!.has(m.id)) this.messagesMap!.set(m.id, m); + } + + const members = (await alasql.promise( + `SELECT * FROM members`, + )) as RoomMember[]; + for (const m of members) { + const key = `${m.roomId}::${m.userId}`; + if (!this.membersMap!.has(key)) this.membersMap!.set(key, m); + } + } + + private schedulePersist(): void { + if (this.persistTimer) return; + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + this.persistToDb(); + }, PERSIST_DEBOUNCE_MS); + } + + private persistToDb(): void { + if (!this.dbReady) return; + try { + alasql(`DELETE FROM rooms`); + this.roomsMap?.forEach((v) => { + const r = v as ChatRoom; + alasql(`INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?)`, [ + r.id, r.name, r.description, r.type, + r.creatorId, r.createdAt, r.updatedAt, + ]); + }); + + alasql(`DELETE FROM messages`); + this.messagesMap?.forEach((v) => { + const m = v as ChatMessage; + alasql(`INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)`, [ + m.id, m.roomId, m.authorId, m.authorName, m.text, m.timestamp, + ]); + }); + + alasql(`DELETE FROM members`); + this.membersMap?.forEach((v: any) => { + alasql(`INSERT INTO members VALUES (?, ?, ?, ?)`, [ + v.roomId, v.userId, v.userName, v.joinedAt, + ]); + }); + } catch { + /* persistence is best-effort */ + } + } + // ── Rooms ────────────────────────────────────────────────────────────── async createRoom( @@ -136,9 +269,16 @@ export class YjsChatStore implements IChatStore { this.assert(); const roomId = id ?? uid(); const now = Date.now(); - const room: ChatRoom = { id: roomId, name, description, type, creatorId, createdAt: now, updatedAt: now }; - this.roomsMap!.set(roomId, room); - this.addMemberEntry(roomId, creatorId, creatorName, now); + const room: ChatRoom = { + id: roomId, name, description, type, + creatorId, createdAt: now, updatedAt: now, + }; + this.ydoc!.transact(() => { + this.roomsMap!.set(roomId, room); + this.membersMap!.set(`${roomId}::${creatorId}`, { + roomId, userId: creatorId, userName: creatorName, joinedAt: now, + } as RoomMember); + }); return room; } @@ -178,7 +318,10 @@ export class YjsChatStore implements IChatStore { return rooms; } - async getSearchableRooms(userId: string, query: string): Promise { + async getSearchableRooms( + userId: string, + query: string, + ): Promise { this.assert(); const memberRoomIds = new Set(); this.membersMap!.forEach((v: any) => { @@ -190,7 +333,10 @@ export class YjsChatStore implements IChatStore { const r = v as ChatRoom; if (r.type !== "public") return; if (memberRoomIds.has(r.id)) return; - if (r.name.toLowerCase().includes(lq) || r.description.toLowerCase().includes(lq)) { + if ( + r.name.toLowerCase().includes(lq) || + r.description.toLowerCase().includes(lq) + ) { rooms.push(r); } }); @@ -213,30 +359,23 @@ export class YjsChatStore implements IChatStore { // ── Membership ───────────────────────────────────────────────────────── - private memberKey(roomId: string, userId: string) { - return `${roomId}::${userId}`; - } - - private addMemberEntry(roomId: string, userId: string, userName: string, joinedAt: number) { - this.membersMap!.set(this.memberKey(roomId, userId), { - roomId, - userId, - userName, - joinedAt, - } as RoomMember); - } - - async joinRoom(roomId: string, userId: string, userName: string): Promise { + async joinRoom( + roomId: string, + userId: string, + userName: string, + ): Promise { this.assert(); - const key = this.memberKey(roomId, userId); + const key = `${roomId}::${userId}`; if (this.membersMap!.has(key)) return true; - this.addMemberEntry(roomId, userId, userName, Date.now()); + this.membersMap!.set(key, { + roomId, userId, userName, joinedAt: Date.now(), + } as RoomMember); return true; } async leaveRoom(roomId: string, userId: string): Promise { this.assert(); - this.membersMap!.delete(this.memberKey(roomId, userId)); + this.membersMap!.delete(`${roomId}::${userId}`); return true; } @@ -252,7 +391,7 @@ export class YjsChatStore implements IChatStore { async isMember(roomId: string, userId: string): Promise { this.assert(); - return this.membersMap!.has(this.memberKey(roomId, userId)); + return this.membersMap!.has(`${roomId}::${userId}`); } // ── Messages ─────────────────────────────────────────────────────────── @@ -273,11 +412,13 @@ export class YjsChatStore implements IChatStore { text, timestamp: Date.now(), }; - this.messagesMap!.set(msg.id, msg); - const room = this.roomsMap!.get(roomId) as ChatRoom | undefined; - if (room) { - this.roomsMap!.set(roomId, { ...room, updatedAt: msg.timestamp }); - } + this.ydoc!.transact(() => { + this.messagesMap!.set(msg.id, msg); + const room = this.roomsMap!.get(roomId) as ChatRoom | undefined; + if (room) { + this.roomsMap!.set(roomId, { ...room, updatedAt: msg.timestamp }); + } + }); return msg; } @@ -294,21 +435,14 @@ export class YjsChatStore implements IChatStore { // ── Typing ───────────────────────────────────────────────────────────── - private typingKey(roomId: string, userId: string) { - return `${roomId}::${userId}`; - } - startTyping(roomId: string, userId: string, userName: string): void { - this.typingYMap?.set(this.typingKey(roomId, userId), { - userId, - userName, - roomId, - startedAt: Date.now(), + this.typingYMap?.set(`${roomId}::${userId}`, { + userId, userName, roomId, startedAt: Date.now(), } as TypingUser); } stopTyping(roomId: string, userId: string): void { - this.typingYMap?.delete(this.typingKey(roomId, userId)); + this.typingYMap?.delete(`${roomId}::${userId}`); } getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { @@ -331,6 +465,7 @@ export class YjsChatStore implements IChatStore { // ── Internal ─────────────────────────────────────────────────────────── private assert(): void { - if (!this.ready) throw new Error("YjsChatStore not initialized. Call init() first."); + if (!this.ready) + throw new Error("ChatStore not initialized. Call init() first."); } } diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/HybridChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/HybridChatStore.ts deleted file mode 100644 index 7eb2078fd..000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/HybridChatStore.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { LocalChatStore } from "./LocalChatStore"; -import { YjsChatStore } from "./YjsChatStore"; -import type { - IChatStore, - ChatMessage, - ChatRoom, - RoomMember, - TypingUser, - ChangeType, - ChatStoreListener, -} from "./types"; -import { uid } from "./types"; - -/** - * Wraps both a LocalChatStore and a YjsChatStore. Writes go to both; - * reads prefer Yjs when the WebSocket is connected, otherwise fall back to - * local. IDs are generated once and passed to both sub-stores so the same - * logical entity keeps a consistent identity across both. - */ -export class HybridChatStore implements IChatStore { - private local: LocalChatStore; - private yjs: YjsChatStore; - private listeners = new Set(); - private ready = false; - - constructor(applicationId: string, wsUrl: string) { - this.local = new LocalChatStore(applicationId); - this.yjs = new YjsChatStore(applicationId, wsUrl); - } - - isReady(): boolean { - return this.ready; - } - - getConnectionLabel(): string { - if (!this.ready) return "Connecting..."; - const yjsLabel = this.yjs.getConnectionLabel(); - return `Hybrid (${yjsLabel})`; - } - - async init(): Promise { - if (this.ready) return; - await this.local.init(); - try { - await this.yjs.init(); - } catch { /* yjs offline, that's fine */ } - this.ready = true; - - this.local.subscribe((changes) => this.notify(changes)); - this.yjs.subscribe((changes) => this.notify(changes)); - } - - destroy(): void { - this.local.destroy(); - this.yjs.destroy(); - this.listeners.clear(); - this.ready = false; - } - - subscribe(listener: ChatStoreListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(changes: Set): void { - this.listeners.forEach((fn) => fn(changes)); - } - - private get reader(): IChatStore { - return this.yjs.isReady() ? this.yjs : this.local; - } - - // ── Rooms (write to both with shared ID, read from best available) ──── - - async createRoom( - name: string, - type: "public" | "private", - creatorId: string, - creatorName: string, - description?: string, - id?: string, - ): Promise { - const sharedId = id ?? uid(); - const room = await this.local.createRoom(name, type, creatorId, creatorName, description, sharedId); - if (this.yjs.isReady()) { - try { - await this.yjs.createRoom(name, type, creatorId, creatorName, description, sharedId); - } catch { /* offline */ } - } - return room; - } - - async getRoom(roomId: string) { return this.reader.getRoom(roomId); } - async getRoomByName(name: string) { return this.reader.getRoomByName(name); } - async getAllRooms() { return this.reader.getAllRooms(); } - async getUserRooms(userId: string) { return this.reader.getUserRooms(userId); } - async getSearchableRooms(userId: string, query: string) { return this.reader.getSearchableRooms(userId, query); } - - async ensureRoom( - name: string, - type: "public" | "private", - creatorId: string, - creatorName: string, - ): Promise { - const room = await this.local.ensureRoom(name, type, creatorId, creatorName); - if (this.yjs.isReady()) { - try { - await this.yjs.ensureRoom(name, type, creatorId, creatorName); - } catch { /* offline */ } - } - return room; - } - - // ── Membership (write to both) ───────────────────────────────────────── - - async joinRoom(roomId: string, userId: string, userName: string): Promise { - const ok = await this.local.joinRoom(roomId, userId, userName); - if (this.yjs.isReady()) { - try { await this.yjs.joinRoom(roomId, userId, userName); } catch { /* offline */ } - } - return ok; - } - - async leaveRoom(roomId: string, userId: string): Promise { - const ok = await this.local.leaveRoom(roomId, userId); - if (this.yjs.isReady()) { - try { await this.yjs.leaveRoom(roomId, userId); } catch { /* offline */ } - } - return ok; - } - - async getRoomMembers(roomId: string) { return this.reader.getRoomMembers(roomId); } - async isMember(roomId: string, userId: string) { return this.reader.isMember(roomId, userId); } - - // ── Messages (write to both with shared ID, read from best) ─────────── - - async sendMessage( - roomId: string, - authorId: string, - authorName: string, - text: string, - id?: string, - ): Promise { - const sharedId = id ?? uid(); - const msg = await this.local.sendMessage(roomId, authorId, authorName, text, sharedId); - if (this.yjs.isReady()) { - try { - await this.yjs.sendMessage(roomId, authorId, authorName, text, sharedId); - } catch { /* offline */ } - } - return msg; - } - - async getMessages(roomId: string, limit?: number) { return this.reader.getMessages(roomId, limit); } - - // ── Typing (prefer Yjs for real-time sync, fallback to local) ───────── - - startTyping(roomId: string, userId: string, userName: string): void { - if (this.yjs.isReady()) { - this.yjs.startTyping(roomId, userId, userName); - } else { - this.local.startTyping(roomId, userId, userName); - } - } - - stopTyping(roomId: string, userId: string): void { - if (this.yjs.isReady()) { - this.yjs.stopTyping(roomId, userId); - } else { - this.local.stopTyping(roomId, userId); - } - } - - getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { - return this.yjs.isReady() - ? this.yjs.getTypingUsers(roomId, excludeUserId) - : this.local.getTypingUsers(roomId, excludeUserId); - } -} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/LocalChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/LocalChatStore.ts deleted file mode 100644 index 8bf41bb38..000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/LocalChatStore.ts +++ /dev/null @@ -1,304 +0,0 @@ -import alasql from "alasql"; -import type { - IChatStore, - ChatMessage, - ChatRoom, - RoomMember, - TypingUser, - ChangeType, - ChatStoreListener, -} from "./types"; -import { uid } from "./types"; - -const CROSS_TAB_EVENT = "chatbox-v2-update"; - -export class LocalChatStore implements IChatStore { - private dbName: string; - private ready = false; - private listeners = new Set(); - private typingMap = new Map(); - - constructor(applicationId: string) { - this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`; - } - - isReady(): boolean { - return this.ready; - } - - getConnectionLabel(): string { - return this.ready ? "Local" : "Connecting..."; - } - - async init(): Promise { - if (this.ready) return; - alasql.options.autocommit = true; - - await alasql.promise(`CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${this.dbName}`); - await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${this.dbName}`); - await alasql.promise(`USE ${this.dbName}`); - - await alasql.promise(` - CREATE TABLE IF NOT EXISTS rooms ( - id STRING PRIMARY KEY, name STRING, description STRING, - type STRING, creatorId STRING, createdAt NUMBER, updatedAt NUMBER - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS messages ( - id STRING PRIMARY KEY, roomId STRING, authorId STRING, - authorName STRING, text STRING, timestamp NUMBER - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS members ( - roomId STRING, userId STRING, userName STRING, joinedAt NUMBER - ) - `); - this.ready = true; - - if (typeof window !== "undefined") { - window.addEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate); - } - } - - destroy(): void { - if (typeof window !== "undefined") { - window.removeEventListener(CROSS_TAB_EVENT, this.onCrossTabUpdate); - } - this.listeners.clear(); - } - - subscribe(listener: ChatStoreListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(changes: Set): void { - this.listeners.forEach((fn) => fn(changes)); - if (typeof window !== "undefined") { - try { - window.dispatchEvent(new CustomEvent(CROSS_TAB_EVENT)); - } catch { /* noop */ } - } - } - - private onCrossTabUpdate = () => { - this.listeners.forEach((fn) => - fn(new Set(["rooms", "messages", "members"])), - ); - }; - - // ── Rooms ────────────────────────────────────────────────────────────── - - async createRoom( - name: string, - type: "public" | "private", - creatorId: string, - creatorName: string, - description = "", - id?: string, - ): Promise { - this.assert(); - const roomId = id ?? uid(); - const now = Date.now(); - await alasql.promise( - `INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?)`, - [roomId, name, description, type, creatorId, now, now], - ); - await alasql.promise( - `INSERT INTO members VALUES (?, ?, ?, ?)`, - [roomId, creatorId, creatorName, now], - ); - this.notify(new Set(["rooms", "members"])); - return { id: roomId, name, description, type, creatorId, createdAt: now, updatedAt: now }; - } - - async getRoom(roomId: string): Promise { - this.assert(); - const rows = (await alasql.promise( - `SELECT * FROM rooms WHERE id = ?`, - [roomId], - )) as ChatRoom[]; - return rows.length > 0 ? rows[0] : null; - } - - async getRoomByName(name: string): Promise { - this.assert(); - const rows = (await alasql.promise( - `SELECT * FROM rooms WHERE name = ?`, - [name], - )) as ChatRoom[]; - return rows.length > 0 ? rows[0] : null; - } - - async getAllRooms(): Promise { - this.assert(); - return (await alasql.promise( - `SELECT * FROM rooms ORDER BY updatedAt DESC`, - )) as ChatRoom[]; - } - - async getUserRooms(userId: string): Promise { - this.assert(); - return (await alasql.promise( - `SELECT r.* FROM rooms r JOIN members m ON r.id = m.roomId WHERE m.userId = ? ORDER BY r.updatedAt DESC`, - [userId], - )) as ChatRoom[]; - } - - async getSearchableRooms(userId: string, query: string): Promise { - this.assert(); - const q = `%${query}%`; - return (await alasql.promise( - `SELECT DISTINCT r.* FROM rooms r - WHERE r.type = 'public' - AND r.id NOT IN (SELECT roomId FROM members WHERE userId = ?) - AND (r.name LIKE ? OR r.description LIKE ?) - ORDER BY r.updatedAt DESC`, - [userId, q, q], - )) as ChatRoom[]; - } - - async ensureRoom( - name: string, - type: "public" | "private", - creatorId: string, - creatorName: string, - ): Promise { - let room = await this.getRoomByName(name); - if (!room) room = await this.createRoom(name, type, creatorId, creatorName); - if (!(await this.isMember(room.id, creatorId))) - await this.joinRoom(room.id, creatorId, creatorName); - return room; - } - - // ── Membership ───────────────────────────────────────────────────────── - - async joinRoom(roomId: string, userId: string, userName: string): Promise { - this.assert(); - const existing = (await alasql.promise( - `SELECT * FROM members WHERE roomId = ? AND userId = ?`, - [roomId, userId], - )) as RoomMember[]; - if (existing.length > 0) return true; - await alasql.promise( - `INSERT INTO members VALUES (?, ?, ?, ?)`, - [roomId, userId, userName, Date.now()], - ); - this.notify(new Set(["members"])); - return true; - } - - async leaveRoom(roomId: string, userId: string): Promise { - this.assert(); - await alasql.promise( - `DELETE FROM members WHERE roomId = ? AND userId = ?`, - [roomId, userId], - ); - this.notify(new Set(["members"])); - return true; - } - - async getRoomMembers(roomId: string): Promise { - this.assert(); - return (await alasql.promise( - `SELECT * FROM members WHERE roomId = ? ORDER BY joinedAt ASC`, - [roomId], - )) as RoomMember[]; - } - - async isMember(roomId: string, userId: string): Promise { - this.assert(); - const rows = (await alasql.promise( - `SELECT * FROM members WHERE roomId = ? AND userId = ?`, - [roomId, userId], - )) as RoomMember[]; - return rows.length > 0; - } - - // ── Messages ─────────────────────────────────────────────────────────── - - async sendMessage( - roomId: string, - authorId: string, - authorName: string, - text: string, - id?: string, - ): Promise { - this.assert(); - const msg: ChatMessage = { - id: id ?? uid(), - roomId, - authorId, - authorName, - text, - timestamp: Date.now(), - }; - await alasql.promise( - `INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)`, - [msg.id, msg.roomId, msg.authorId, msg.authorName, msg.text, msg.timestamp], - ); - await alasql.promise( - `UPDATE rooms SET updatedAt = ? WHERE id = ?`, - [msg.timestamp, roomId], - ); - this.notify(new Set(["messages", "rooms"])); - return msg; - } - - async getMessages(roomId: string, limit = 100): Promise { - this.assert(); - const rows = (await alasql.promise( - `SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC`, - [roomId], - )) as ChatMessage[]; - return rows.slice(-limit); - } - - // ── Typing ───────────────────────────────────────────────────────────── - - private typingKey(roomId: string, userId: string) { - return `${roomId}::${userId}`; - } - - startTyping(roomId: string, userId: string, userName: string): void { - this.typingMap.set(this.typingKey(roomId, userId), { - userId, - userName, - roomId, - startedAt: Date.now(), - }); - this.notify(new Set(["typing"])); - } - - stopTyping(roomId: string, userId: string): void { - if (this.typingMap.delete(this.typingKey(roomId, userId))) { - this.notify(new Set(["typing"])); - } - } - - getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { - const now = Date.now(); - const result: TypingUser[] = []; - let expired = false; - for (const [key, entry] of this.typingMap) { - if (entry.roomId !== roomId) continue; - if (excludeUserId && entry.userId === excludeUserId) continue; - if (now - entry.startedAt > 5000) { - this.typingMap.delete(key); - expired = true; - continue; - } - result.push(entry); - } - if (expired) this.notify(new Set(["typing"])); - return result; - } - - // ── Internal ─────────────────────────────────────────────────────────── - - private assert(): void { - if (!this.ready) throw new Error("LocalChatStore not initialized. Call init() first."); - } -} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts index 8cfd27f35..0ca59da6c 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts @@ -5,79 +5,43 @@ export type { TypingUser, ChangeType, ChatStoreListener, - SyncMode, - IChatStore, } from "./types"; export { uid } from "./types"; -export { LocalChatStore } from "./LocalChatStore"; -export { YjsChatStore } from "./YjsChatStore"; -export { HybridChatStore } from "./HybridChatStore"; +export { ChatStore } from "./ChatStore"; -import type { IChatStore, SyncMode } from "./types"; -import { LocalChatStore } from "./LocalChatStore"; -import { YjsChatStore } from "./YjsChatStore"; -import { HybridChatStore } from "./HybridChatStore"; +import { ChatStore } from "./ChatStore"; -// ─── Factory with reference-counted cache ──────────────────────────────────── +// ─── Reference-counted singleton cache ─────────────────────────────────────── interface CacheEntry { - store: IChatStore; + store: ChatStore; refCount: number; } const storeCache = new Map(); -function cacheKey(applicationId: string, mode: SyncMode): string { - return `${applicationId}__${mode}`; -} - -/** - * Returns (or creates) a store for the given application/mode pair and - * increments its reference count. Callers MUST call `releaseChatStore` - * when they no longer need the store. - */ export function getChatStore( applicationId: string, - mode: SyncMode = "local", wsUrl = "ws://localhost:3005", -): IChatStore { - const key = cacheKey(applicationId, mode); - const entry = storeCache.get(key); +): ChatStore { + const entry = storeCache.get(applicationId); if (entry) { entry.refCount++; return entry.store; } - let store: IChatStore; - switch (mode) { - case "collaborative": - store = new YjsChatStore(applicationId, wsUrl); - break; - case "hybrid": - store = new HybridChatStore(applicationId, wsUrl); - break; - default: - store = new LocalChatStore(applicationId); - } - storeCache.set(key, { store, refCount: 1 }); + const store = new ChatStore(applicationId, wsUrl); + storeCache.set(applicationId, { store, refCount: 1 }); return store; } -/** - * Decrements the reference count for the given store key. When the count - * reaches zero the store is destroyed and evicted from the cache. - */ -export function releaseChatStore( - applicationId: string, - mode: SyncMode = "local", -): void { - const key = cacheKey(applicationId, mode); - const entry = storeCache.get(key); +export function releaseChatStore(applicationId: string): void { + const entry = storeCache.get(applicationId); if (!entry) return; entry.refCount--; if (entry.refCount <= 0) { entry.store.destroy(); - storeCache.delete(key); + storeCache.delete(applicationId); } } diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts index 55e8a3103..8f2b48d60 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts @@ -35,54 +35,6 @@ export type ChangeType = "rooms" | "messages" | "members" | "typing" | "connecti export type ChatStoreListener = (changes: Set) => void; -export type SyncMode = "local" | "collaborative" | "hybrid"; - -export interface IChatStore { - init(): Promise; - destroy(): void; - subscribe(listener: ChatStoreListener): () => void; - isReady(): boolean; - getConnectionLabel(): string; - - createRoom( - name: string, - type: "public" | "private", - creatorId: string, - creatorName: string, - description?: string, - id?: string, - ): Promise; - getRoom(roomId: string): Promise; - getRoomByName(name: string): Promise; - getAllRooms(): Promise; - getUserRooms(userId: string): Promise; - getSearchableRooms(userId: string, query: string): Promise; - ensureRoom( - name: string, - type: "public" | "private", - creatorId: string, - creatorName: string, - ): Promise; - - joinRoom(roomId: string, userId: string, userName: string): Promise; - leaveRoom(roomId: string, userId: string): Promise; - getRoomMembers(roomId: string): Promise; - isMember(roomId: string, userId: string): Promise; - - sendMessage( - roomId: string, - authorId: string, - authorName: string, - text: string, - id?: string, - ): Promise; - getMessages(roomId: string, limit?: number): Promise; - - startTyping(roomId: string, userId: string, userName: string): void; - stopTyping(roomId: string, userId: string): void; - getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[]; -} - export function uid(): string { return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; } diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts index 1ab650f35..f3a7d2a49 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -1,21 +1,19 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { - IChatStore, ChatMessage, ChatRoom, RoomMember, TypingUser, - SyncMode, ChangeType, } from "./store"; import { getChatStore, releaseChatStore } from "./store"; +import type { ChatStore } from "./store"; export interface UseChatStoreConfig { applicationId: string; defaultRoom: string; userId: string; userName: string; - mode: SyncMode; wsUrl: string; } @@ -43,9 +41,9 @@ export interface UseChatStoreReturn { const TYPING_POLL_INTERVAL = 1500; export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { - const { applicationId, defaultRoom, userId, userName, mode, wsUrl } = config; + const { applicationId, defaultRoom, userId, userName, wsUrl } = config; - const storeRef = useRef(null); + const storeRef = useRef(null); const [ready, setReady] = useState(false); const [error, setError] = useState(null); const [connectionLabel, setConnectionLabel] = useState("Connecting..."); @@ -130,7 +128,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { const handleStoreChange = useCallback( (changes: Set) => { - if (changes.has("rooms")) refreshRooms(); + if (changes.has("rooms") || changes.has("members")) refreshRooms(); if (changes.has("messages")) refreshMessages(); if (changes.has("members")) refreshMembers(); if (changes.has("connection")) refreshConnection(); @@ -148,7 +146,7 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { if (!applicationId || !userId || !userName) return; let cancelled = false; - const store = getChatStore(applicationId, mode, wsUrl); + const store = getChatStore(applicationId, wsUrl); storeRef.current = store; (async () => { @@ -188,9 +186,9 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { cancelled = true; unsub(); stopTypingPoll(); - releaseChatStore(applicationId, mode); + releaseChatStore(applicationId); }; - }, [applicationId, userId, userName, defaultRoom, mode, wsUrl, handleStoreChange, stopTypingPoll]); + }, [applicationId, userId, userName, defaultRoom, wsUrl, handleStoreChange, stopTypingPoll]); // ── Actions ──────────────────────────────────────────────────────────── From 7d4004190395f6d8b69fbbe20caba6a117d031cb Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 6 Mar 2026 16:26:36 +0500 Subject: [PATCH 23/34] add testing chat controller --- .../components/MessageList.tsx | 6 - .../src/comps/hooks/chatControllerV2Comp.tsx | 354 ++++++++++++++++++ .../lowcoder/src/comps/hooks/hookComp.tsx | 2 + .../src/comps/hooks/hookCompTypes.tsx | 7 +- client/packages/lowcoder/src/comps/index.tsx | 11 + .../lowcoder/src/comps/uiCompRegistry.ts | 1 + .../src/pages/editor/editorConstants.tsx | 1 + 7 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx index 3ee53a21c..376026113 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx @@ -20,11 +20,6 @@ export interface MessageListProps { export const MessageList = React.memo((props: MessageListProps) => { const { messages, typingUsers, currentUserId, ready } = props; - const endRef = useRef(null); - - useEffect(() => { - endRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); return ( @@ -67,7 +62,6 @@ export const MessageList = React.memo((props: MessageListProps) => { )} -
); }); diff --git a/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx new file mode 100644 index 000000000..af90fbc9a --- /dev/null +++ b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx @@ -0,0 +1,354 @@ +import React, { useEffect, useRef } from "react"; +import { Section, sectionNames } from "lowcoder-design"; +import { + simpleMultiComp, + stateComp, + withDefault, + withPropertyViewFn, + withViewFn, +} from "../generators"; +import { NameConfig, withExposingConfigs } from "../generators/withExposing"; +import { withMethodExposing } from "../generators/withMethodExposing"; +import { stringExposingStateControl } from "comps/controls/codeStateControl"; +import { StringControl } from "comps/controls/codeControl"; +import { eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { JSONObject } from "../../util/jsonTypes"; +import { useChatStore, UseChatStoreReturn } from "../comps/chatBoxComponentv2/useChatStore"; + +// ─── Event definitions ────────────────────────────────────────────────────── + +const ChatControllerEvents = [ + { label: "Message Sent", value: "messageSent", description: "Triggered when the current user sends a message" }, + { label: "Message Received", value: "messageReceived", description: "Triggered when a message is received from another user" }, + { label: "Room Joined", value: "roomJoined", description: "Triggered when the user joins a room" }, + { label: "Room Left", value: "roomLeft", description: "Triggered when the user leaves a room" }, + { label: "Connected", value: "connected", description: "Triggered when the chat store is ready" }, + { label: "Error", value: "error", description: "Triggered when an error occurs" }, +] as const; + +// ─── Children map ─────────────────────────────────────────────────────────── + +const childrenMap = { + // Configuration (shown in property panel, readable & writable) + applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), + userId: stringExposingStateControl("userId", "user_1"), + userName: stringExposingStateControl("userName", "User"), + defaultRoom: withDefault(StringControl, "general"), + wsUrl: withDefault(StringControl, "ws://localhost:3005"), + + // Events + onEvent: eventHandlerControl(ChatControllerEvents), + + // Reactive state (synced from useChatStore, exposed to users) + ready: stateComp(false), + error: stateComp(null), + connectionStatus: stateComp("Connecting..."), + currentRoom: stateComp(null), + messages: stateComp([]), + userRooms: stateComp([]), + currentRoomMembers: stateComp([]), + typingUsers: stateComp([]), + + // Internal: holds useChatStore actions so withMethodExposing can call them + _chatActions: stateComp({}), +}; + +// ─── View function (headless — returns null) ──────────────────────────────── + +const ChatControllerV2Base = withViewFn( + simpleMultiComp(childrenMap), + (comp) => { + const userId = comp.children.userId.getView().value; + const userName = comp.children.userName.getView().value; + const applicationId = comp.children.applicationId.getView().value; + const defaultRoom = comp.children.defaultRoom.getView(); + const wsUrl = comp.children.wsUrl.getView(); + + const chat = useChatStore({ + applicationId: applicationId || "lowcoder_app", + defaultRoom: defaultRoom || "general", + userId: userId || "user_1", + userName: userName || "User", + wsUrl: wsUrl || "ws://localhost:3005", + }); + + const prevRef = useRef<{ + ready: boolean; + msgCount: number; + roomId: string | null; + }>({ ready: false, msgCount: 0, roomId: null }); + + const triggerEvent = comp.children.onEvent.getView(); + + // ── Sync ready ───────────────────────────────────────────────────── + useEffect(() => { + comp.children.ready.dispatchChangeValueAction(chat.ready); + if (chat.ready && !prevRef.current.ready) { + triggerEvent("connected"); + } + prevRef.current.ready = chat.ready; + }, [chat.ready]); + + // ── Sync error ───────────────────────────────────────────────────── + useEffect(() => { + comp.children.error.dispatchChangeValueAction(chat.error); + if (chat.error) { + triggerEvent("error"); + } + }, [chat.error]); + + // ── Sync connection status ───────────────────────────────────────── + useEffect(() => { + comp.children.connectionStatus.dispatchChangeValueAction(chat.connectionLabel); + }, [chat.connectionLabel]); + + // ── Sync currentRoom ─────────────────────────────────────────────── + useEffect(() => { + comp.children.currentRoom.dispatchChangeValueAction( + chat.currentRoom as unknown as JSONObject | null, + ); + const newRoomId = chat.currentRoom?.id ?? null; + if (newRoomId && newRoomId !== prevRef.current.roomId) { + triggerEvent("roomJoined"); + } + prevRef.current.roomId = newRoomId; + }, [chat.currentRoom]); + + // ── Sync messages ────────────────────────────────────────────────── + useEffect(() => { + comp.children.messages.dispatchChangeValueAction( + chat.messages as unknown as JSONObject[], + ); + const newCount = chat.messages.length; + if (newCount > prevRef.current.msgCount && prevRef.current.msgCount > 0) { + const lastMsg = chat.messages[newCount - 1]; + if (lastMsg?.authorId === userId) { + triggerEvent("messageSent"); + } else { + triggerEvent("messageReceived"); + } + } + prevRef.current.msgCount = newCount; + }, [chat.messages, userId]); + + // ── Sync userRooms ───────────────────────────────────────────────── + useEffect(() => { + comp.children.userRooms.dispatchChangeValueAction( + chat.userRooms as unknown as JSONObject[], + ); + }, [chat.userRooms]); + + // ── Sync currentRoomMembers ──────────────────────────────────────── + useEffect(() => { + comp.children.currentRoomMembers.dispatchChangeValueAction( + chat.currentRoomMembers as unknown as JSONObject[], + ); + }, [chat.currentRoomMembers]); + + // ── Sync typingUsers ─────────────────────────────────────────────── + useEffect(() => { + comp.children.typingUsers.dispatchChangeValueAction( + chat.typingUsers as unknown as JSONObject[], + ); + }, [chat.typingUsers]); + + // ── Store actions for method access ──────────────────────────────── + useEffect(() => { + comp.children._chatActions.dispatchChangeValueAction( + chat as unknown as JSONObject, + ); + }, [chat.ready, chat.currentRoom]); + + return null; + }, +); + +// ─── Property panel ───────────────────────────────────────────────────────── + +const ChatControllerV2WithProps = withPropertyViewFn(ChatControllerV2Base, (comp) => ( + <> +
+ {comp.children.applicationId.propertyView({ + label: "Application ID", + tooltip: "Scopes chat rooms to this application", + })} + {comp.children.userId.propertyView({ + label: "User ID", + tooltip: "Current user's unique identifier", + })} + {comp.children.userName.propertyView({ + label: "User Name", + tooltip: "Current user's display name", + })} + {comp.children.defaultRoom.propertyView({ + label: "Default Room", + tooltip: "Room to auto-join on initialization", + })} + {comp.children.wsUrl.propertyView({ + label: "WebSocket URL", + tooltip: "Yjs WebSocket server URL for real-time sync", + })} +
+
+ {comp.children.onEvent.getPropertyView()} +
+ +)); + +// ─── Expose state properties ──────────────────────────────────────────────── + +let ChatControllerV2Comp = withExposingConfigs(ChatControllerV2WithProps, [ + new NameConfig("ready", "Whether the chat store is initialized and ready"), + new NameConfig("error", "Error message if initialization failed"), + new NameConfig("connectionStatus", "Current connection status label"), + new NameConfig("currentRoom", "Currently active chat room object"), + new NameConfig("messages", "Messages in the current room"), + new NameConfig("userRooms", "Rooms the current user has joined"), + new NameConfig("currentRoomMembers", "Members of the current room"), + new NameConfig("typingUsers", "Users currently typing in the current room"), + new NameConfig("userId", "Current user ID"), + new NameConfig("userName", "Current user name"), + new NameConfig("applicationId", "Application scope ID"), +]); + +// ─── Expose methods ───────────────────────────────────────────────────────── + +ChatControllerV2Comp = withMethodExposing(ChatControllerV2Comp, [ + { + method: { + name: "sendMessage", + description: "Send a message to the current room", + params: [{ name: "text", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.sendMessage) { + return await actions.sendMessage(values?.[0] as string); + } + return false; + }, + }, + { + method: { + name: "switchRoom", + description: "Switch to a different room by its ID", + params: [{ name: "roomId", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.switchRoom) { + await actions.switchRoom(values?.[0] as string); + } + }, + }, + { + method: { + name: "createRoom", + description: "Create a new chat room", + params: [ + { name: "name", type: "string" }, + { name: "type", type: "string" }, + { name: "description", type: "string" }, + ], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.createRoom) { + return await actions.createRoom( + values?.[0] as string, + (values?.[1] as "public" | "private") || "public", + values?.[2] as string | undefined, + ); + } + return null; + }, + }, + { + method: { + name: "joinRoom", + description: "Join a room by its ID", + params: [{ name: "roomId", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.joinRoom) { + return await actions.joinRoom(values?.[0] as string); + } + return false; + }, + }, + { + method: { + name: "leaveRoom", + description: "Leave a room by its ID", + params: [{ name: "roomId", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.leaveRoom) { + const ok = await actions.leaveRoom(values?.[0] as string); + if (ok) { + comp.children.onEvent.getView()("roomLeft"); + } + return ok; + } + return false; + }, + }, + { + method: { + name: "searchRooms", + description: "Search for public rooms by query string", + params: [{ name: "query", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.searchRooms) { + return await actions.searchRooms(values?.[0] as string); + } + return []; + }, + }, + { + method: { + name: "startTyping", + description: "Signal that the current user started typing", + params: [], + }, + execute: (comp) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.startTyping) { + actions.startTyping(); + } + }, + }, + { + method: { + name: "stopTyping", + description: "Signal that the current user stopped typing", + params: [], + }, + execute: (comp) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.stopTyping) { + actions.stopTyping(); + } + }, + }, + { + method: { + name: "setUser", + description: "Update the current chat user credentials", + params: [ + { name: "userId", type: "string" }, + { name: "userName", type: "string" }, + ], + }, + execute: (comp, values) => { + if (values?.[0]) comp.children.userId.getView().onChange(values[0] as string); + if (values?.[1]) comp.children.userName.getView().onChange(values[1] as string); + }, + }, +]); + +export { ChatControllerV2Comp }; diff --git a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx index fa4294709..6e6e5f19a 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx @@ -38,6 +38,7 @@ import UrlParamsHookComp from "./UrlParamsHookComp"; import { UtilsComp } from "./utilsComp"; import { ScreenInfoHookComp } from "./screenInfoComp"; import { ChatControllerComp } from "../comps/chatBoxComponent/chatControllerComp"; +import { ChatControllerV2Comp } from "./chatControllerV2Comp"; window._ = _; window.dayjs = dayjs; @@ -120,6 +121,7 @@ const HookMap: HookCompMapRawType = { drawer: DrawerComp, theme: ThemeComp, chatController: ChatControllerComp, + chatControllerV2: ChatControllerV2Comp, }; export const HookTmpComp = withTypeAndChildren(HookMap, "title", { diff --git a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx index 22e79e6d1..ee63a7f6a 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx @@ -19,7 +19,8 @@ const AllHookComp = [ "urlParams", "theme", "meeting", - "chatController" + "chatController", + "chatControllerV2" ] as const; export type HookCompType = (typeof AllHookComp)[number]; @@ -54,6 +55,10 @@ const HookCompConfig: Record< category: "ui", singleton: false, }, + chatControllerV2: { + category: "ui", + singleton: false, + }, lodashJsLib: { category: "hide", }, diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index ef9cba1e9..ebbb019ad 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -196,6 +196,7 @@ import { ContainerComp as FloatTextContainerComp } from "./comps/containerComp/t import { ChatComp } from "./comps/chatComp"; import { ChatBoxComp } from "./comps/chatBoxComponent"; import { ChatControllerComp } from "./comps/chatBoxComponent/chatControllerComp"; +import { ChatControllerV2Comp } from "./hooks/chatControllerV2Comp"; import { ChatBoxV2Comp } from "./comps/chatBoxComponentv2"; type Registry = { @@ -974,6 +975,16 @@ export var uiCompMap: Registry = { isContainer: true, }, + chatControllerV2: { + name: "Chat Controller V2", + enName: "Chat Controller V2", + description: "Headless chat controller — exposes state, methods & events so you can build custom chat UIs with built-in components", + categories: ["collaboration"], + icon: CommentCompIcon, + keywords: "chatbox,chat,controller,headless,rooms,messaging,v2", + comp: ChatControllerV2Comp, + }, + chatBoxV: { name: "Chat Box V2", enName: "Chat Box V2", diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index 35ffab02e..35f80bbb8 100644 --- a/client/packages/lowcoder/src/comps/uiCompRegistry.ts +++ b/client/packages/lowcoder/src/comps/uiCompRegistry.ts @@ -145,6 +145,7 @@ export type UICompType = | "chat" //Added By Kamal Qureshi | "chatBox" //Added By Kamal Qureshi | "chatController" + | "chatControllerV2" | "chatBoxV" | "autocomplete" //Added By Mousheng | "colorPicker" //Added By Mousheng diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index b0bdebf15..97a29ad78 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -309,5 +309,6 @@ export const CompStateIcon: { chat: , chatBox: , chatController: , + chatControllerV2: , chatBoxV: , } as const; From 58598dc32ab0c9a3ceb2aff113e112500d9388d6 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 6 Mar 2026 22:24:28 +0500 Subject: [PATCH 24/34] add typing state via awareness protocol --- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 2 - .../components/ChatBoxView.tsx | 7 +- .../chatBoxComponentv2/store/ChatStore.ts | 56 ++++++++++------ .../comps/chatBoxComponentv2/store/types.ts | 1 - .../comps/chatBoxComponentv2/useChatStore.ts | 64 ++++++------------- 5 files changed, 56 insertions(+), 74 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index 150e52cec..110bc8ae8 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -32,7 +32,6 @@ const childrenMap = { userId: stringExposingStateControl("userId", "user_1"), userName: stringExposingStateControl("userName", "User"), applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), - defaultRoom: withDefault(StringControl, "general"), wsUrl: withDefault(StringControl, "ws://localhost:3005"), allowRoomCreation: withDefault(BoolControl, true), @@ -59,7 +58,6 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => { {children.userId.propertyView({ label: "User ID", tooltip: "Current user's unique identifier" })} {children.userName.propertyView({ label: "User Name", tooltip: "Current user's display name" })} {children.applicationId.propertyView({ label: "Application ID", tooltip: "Scopes rooms to this application" })} - {children.defaultRoom.propertyView({ label: "Default Room", tooltip: "Room to join on load" })} {children.wsUrl.propertyView({ label: "WebSocket URL", tooltip: "Yjs WebSocket server URL for real-time sync (e.g. ws://localhost:3005)", diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx index 71bb5749d..05599d6c6 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -7,12 +7,13 @@ import { MessageList } from "./MessageList"; import { InputBar } from "./InputBar"; import { CreateRoomModal } from "./CreateRoomModal"; +type ChatBoxEventName = "messageSent" | "messageReceived" | "roomJoined" | "roomLeft"; + export interface ChatBoxViewProps { chatName: { value: string }; userId: { value: string }; userName: { value: string }; applicationId: { value: string }; - defaultRoom: string; wsUrl: string; allowRoomCreation: boolean; allowRoomSearch: boolean; @@ -20,7 +21,7 @@ export interface ChatBoxViewProps { roomPanelWidth: string; style: any; animationStyle: any; - onEvent: (event: string) => any; + onEvent: (event: ChatBoxEventName) => any; [key: string]: any; } @@ -36,7 +37,6 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { userId, userName, applicationId, - defaultRoom, wsUrl, allowRoomCreation, allowRoomSearch, @@ -49,7 +49,6 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { const chat = useChatStore({ applicationId: applicationId.value || "lowcoder_app", - defaultRoom: defaultRoom || "general", userId: userId.value || "user_1", userName: userName.value || "User", wsUrl: wsUrl || "ws://localhost:3005", diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts index 6b4601068..7826a7985 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts @@ -25,7 +25,7 @@ export class ChatStore { private messagesMap: Y.Map | null = null; private roomsMap: Y.Map | null = null; private membersMap: Y.Map | null = null; - private typingYMap: Y.Map | null = null; + private awarenessHandler: (() => void) | null = null; private listeners = new Set(); private ready = false; @@ -71,18 +71,19 @@ export class ChatStore { ChatStore.docs.set(docId, ydoc); ChatStore.refCounts.set(docId, 1); isNewDoc = true; + console.log(`[YJS] Created new document: ${docId}`); } else { ChatStore.refCounts.set( docId, (ChatStore.refCounts.get(docId) || 0) + 1, ); + console.log(`[YJS] Reusing existing document: ${docId}`); } this.ydoc = ydoc; this.messagesMap = ydoc.getMap("messages"); this.roomsMap = ydoc.getMap("rooms"); this.membersMap = ydoc.getMap("members"); - this.typingYMap = ydoc.getMap("typing"); if (isNewDoc) { await this.hydrateFromDb(); @@ -90,6 +91,7 @@ export class ChatStore { let wsProvider = ChatStore.providers.get(docId); if (!wsProvider) { + console.log(`[YJS] Creating WebSocket provider for ${docId} at ${this.wsUrl}`); wsProvider = new WebsocketProvider(this.wsUrl, docId, ydoc, { connect: true, }); @@ -98,26 +100,34 @@ export class ChatStore { this.wsProvider = wsProvider; this.messagesMap.observe(() => { + console.log(`[YJS] Messages map changed (size: ${this.messagesMap.size})`); this.schedulePersist(); this.notify(new Set(["messages"])); }); this.roomsMap.observe(() => { + console.log(`[YJS] Rooms map changed (size: ${this.roomsMap.size})`); this.schedulePersist(); this.notify(new Set(["rooms"])); }); this.membersMap.observe(() => { + console.log(`[YJS] Members map changed (size: ${this.membersMap.size})`); this.schedulePersist(); this.notify(new Set(["members"])); }); - this.typingYMap.observe(() => this.notify(new Set(["typing"]))); + + const awarenessHandler = () => this.notify(new Set(["typing"])); + wsProvider.awareness.on("change", awarenessHandler); + this.awarenessHandler = awarenessHandler; wsProvider.on("status", (e: { status: string }) => { this.wsConnected = e.status === "connected"; + console.log(`[YJS] WebSocket status: ${e.status}`); this.notify(new Set(["connection"])); }); this.wsConnected = wsProvider.wsconnected; this.ready = true; + console.log(`[YJS] ChatStore initialized for ${this.applicationId} (${this.wsConnected ? 'online' : 'offline'})`); this.notify(new Set(["connection"])); } @@ -142,12 +152,17 @@ export class ChatStore { } } + if (this.wsProvider && this.awarenessHandler) { + this.wsProvider.awareness.setLocalStateField("typing", null); + this.wsProvider.awareness.off("change", this.awarenessHandler); + this.awarenessHandler = null; + } + this.ydoc = null; this.wsProvider = null; this.messagesMap = null; this.roomsMap = null; this.membersMap = null; - this.typingYMap = null; this.listeners.clear(); this.ready = false; } @@ -273,6 +288,7 @@ export class ChatStore { id: roomId, name, description, type, creatorId, createdAt: now, updatedAt: now, }; + console.log(`[YJS] Creating room: ${name} (${roomId})`); this.ydoc!.transact(() => { this.roomsMap!.set(roomId, room); this.membersMap!.set(`${roomId}::${creatorId}`, { @@ -367,6 +383,7 @@ export class ChatStore { this.assert(); const key = `${roomId}::${userId}`; if (this.membersMap!.has(key)) return true; + console.log(`[YJS] User ${userName} (${userId}) joining room ${roomId}`); this.membersMap!.set(key, { roomId, userId, userName, joinedAt: Date.now(), } as RoomMember); @@ -412,6 +429,7 @@ export class ChatStore { text, timestamp: Date.now(), }; + console.log(`[YJS] Sending message to room ${roomId}: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); this.ydoc!.transact(() => { this.messagesMap!.set(msg.id, msg); const room = this.roomsMap!.get(roomId) as ChatRoom | undefined; @@ -433,31 +451,27 @@ export class ChatStore { return msgs.slice(-limit); } - // ── Typing ───────────────────────────────────────────────────────────── + // ── Typing (via Awareness — ephemeral, auto-clears on disconnect) ────── startTyping(roomId: string, userId: string, userName: string): void { - this.typingYMap?.set(`${roomId}::${userId}`, { - userId, userName, roomId, startedAt: Date.now(), - } as TypingUser); + this.wsProvider?.awareness.setLocalStateField("typing", { userId, userName, roomId }); } - stopTyping(roomId: string, userId: string): void { - this.typingYMap?.delete(`${roomId}::${userId}`); + stopTyping(_roomId: string, _userId: string): void { + this.wsProvider?.awareness.setLocalStateField("typing", null); } getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { - if (!this.typingYMap) return []; - const now = Date.now(); + if (!this.wsProvider) return []; + const myClientId = this.wsProvider.awareness.clientID; const result: TypingUser[] = []; - this.typingYMap.forEach((v: any, key: string) => { - const entry = v as TypingUser; - if (entry.roomId !== roomId) return; - if (excludeUserId && entry.userId === excludeUserId) return; - if (now - entry.startedAt > 5000) { - this.typingYMap!.delete(key); - return; - } - result.push(entry); + this.wsProvider.awareness.getStates().forEach((state, clientId) => { + if (clientId === myClientId) return; + const typing = state.typing as TypingUser | null | undefined; + if (!typing) return; + if (typing.roomId !== roomId) return; + if (excludeUserId && typing.userId === excludeUserId) return; + result.push(typing); }); return result; } diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts index 8f2b48d60..3bc87e55a 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts @@ -28,7 +28,6 @@ export interface TypingUser { userId: string; userName: string; roomId: string; - startedAt: number; } export type ChangeType = "rooms" | "messages" | "members" | "typing" | "connection"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts index f3a7d2a49..9afe86cb1 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -11,7 +11,6 @@ import type { ChatStore } from "./store"; export interface UseChatStoreConfig { applicationId: string; - defaultRoom: string; userId: string; userName: string; wsUrl: string; @@ -38,10 +37,8 @@ export interface UseChatStoreReturn { stopTyping: () => void; } -const TYPING_POLL_INTERVAL = 1500; - export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { - const { applicationId, defaultRoom, userId, userName, wsUrl } = config; + const { applicationId, userId, userName, wsUrl } = config; const storeRef = useRef(null); const [ready, setReady] = useState(false); @@ -55,7 +52,6 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { const [typingUsers, setTypingUsers] = useState([]); const activeRoomIdRef = useRef(null); - const typingPollRef = useRef | null>(null); // ── Granular refresh helpers ────────────────────────────────────────── @@ -102,28 +98,6 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { if (store) setConnectionLabel(store.getConnectionLabel()); }, []); - // ── Smart typing poll: only runs when someone is typing ─────────────── - - const startTypingPoll = useCallback(() => { - if (typingPollRef.current) return; - typingPollRef.current = setInterval(() => { - const users = refreshTyping(); - if (!users || users.length === 0) { - if (typingPollRef.current) { - clearInterval(typingPollRef.current); - typingPollRef.current = null; - } - } - }, TYPING_POLL_INTERVAL); - }, [refreshTyping]); - - const stopTypingPoll = useCallback(() => { - if (typingPollRef.current) { - clearInterval(typingPollRef.current); - typingPollRef.current = null; - } - }, []); - // ── Handle granular store changes ───────────────────────────────────── const handleStoreChange = useCallback( @@ -132,12 +106,9 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { if (changes.has("messages")) refreshMessages(); if (changes.has("members")) refreshMembers(); if (changes.has("connection")) refreshConnection(); - if (changes.has("typing")) { - const users = refreshTyping(); - if (users && users.length > 0) startTypingPoll(); - } + if (changes.has("typing")) refreshTyping(); }, - [refreshRooms, refreshMessages, refreshMembers, refreshConnection, refreshTyping, startTypingPoll], + [refreshRooms, refreshMessages, refreshMembers, refreshConnection, refreshTyping], ); // ── Initialization ───────────────────────────────────────────────────── @@ -154,22 +125,24 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { await store.init(); if (cancelled) return; - const room = await store.ensureRoom(defaultRoom, "public", userId, userName); + const rooms = await store.getUserRooms(userId); if (cancelled) return; - activeRoomIdRef.current = room.id; - setCurrentRoom(room); + setUserRooms(rooms); - const [msgs, rooms, members] = await Promise.all([ - store.getMessages(room.id), - store.getUserRooms(userId), - store.getRoomMembers(room.id), - ]); - if (cancelled) return; + // if (rooms.length > 0) { + // const first = rooms[0]; + // activeRoomIdRef.current = first.id; + // setCurrentRoom(first); + // const [msgs, members] = await Promise.all([ + // store.getMessages(first.id), + // store.getRoomMembers(first.id), + // ]); + // if (cancelled) return; + // setMessages(msgs); + // setCurrentRoomMembers(members); + // } - setMessages(msgs); - setUserRooms(rooms); - setCurrentRoomMembers(members); setConnectionLabel(store.getConnectionLabel()); setReady(true); } catch (e) { @@ -185,10 +158,9 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { return () => { cancelled = true; unsub(); - stopTypingPoll(); releaseChatStore(applicationId); }; - }, [applicationId, userId, userName, defaultRoom, wsUrl, handleStoreChange, stopTypingPoll]); + }, [applicationId, userId, userName, wsUrl, handleStoreChange]); // ── Actions ──────────────────────────────────────────────────────────── From 9622f2de4321853c79e051e9401baf6faa0bccac Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Mon, 9 Mar 2026 23:58:44 +0500 Subject: [PATCH 25/34] add LLM chat room for chatbox --- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 73 ++++++- .../components/ChatBoxView.tsx | 83 ++++++- .../components/CreateRoomModal.tsx | 173 +++++++++++++-- .../components/InputBar.tsx | 20 +- .../components/MessageList.tsx | 110 +++++++++- .../components/RoomPanel.tsx | 11 +- .../chatBoxComponentv2/store/ChatStore.ts | 99 +++++---- .../comps/chatBoxComponentv2/store/index.ts | 2 +- .../comps/chatBoxComponentv2/store/types.ts | 10 +- .../comps/comps/chatBoxComponentv2/styles.ts | 139 ++++++++++++ .../comps/chatBoxComponentv2/useChatStore.ts | 204 +++++++++++++++--- 11 files changed, 804 insertions(+), 120 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index 110bc8ae8..4195354fa 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -1,9 +1,9 @@ -import React, { useContext } from "react"; +import React, { useCallback, useContext, useEffect, useRef } from "react"; import { Section, sectionNames } from "lowcoder-design"; import { UICompBuilder, withDefault } from "../../generators"; import { NameConfig, NameConfigHidden, withExposingConfigs } from "../../generators/withExposing"; import { withMethodExposing } from "../../generators/withMethodExposing"; -import { stringExposingStateControl } from "comps/controls/codeStateControl"; +import { stringExposingStateControl, arrayObjectExposingStateControl } from "comps/controls/codeStateControl"; import { BoolControl } from "comps/controls/boolControl"; import { StringControl } from "comps/controls/codeControl"; import { AutoHeightControl } from "comps/controls/autoHeightControl"; @@ -16,36 +16,54 @@ import { trans } from "i18n"; import { ChatBoxView } from "./components/ChatBoxView"; -// ─── Event definitions ────────────────────────────────────────────────────── +// ─── Event definitions ─────────────────────────────────────────────────────── const ChatEvents = [ { label: trans("chatBox.messageSent"), value: "messageSent", description: trans("chatBox.messageSentDesc") }, { label: trans("chatBox.messageReceived"), value: "messageReceived", description: trans("chatBox.messageReceivedDesc") }, { label: trans("chatBox.roomJoined"), value: "roomJoined", description: trans("chatBox.roomJoinedDesc") }, { label: trans("chatBox.roomLeft"), value: "roomLeft", description: trans("chatBox.roomLeftDesc") }, + { + label: "LLM Message Received", + value: "llmMessageReceived", + description: "Fired when an AI response arrives in an LLM room", + }, ] as const; -// ─── Children map (component properties) ──────────────────────────────────── +// ─── Children map (component properties) ───────────────────────────────────── const childrenMap = { + // ── Identity / connection chatName: stringExposingStateControl("chatName", "Chat Room"), userId: stringExposingStateControl("userId", "user_1"), userName: stringExposingStateControl("userName", "User"), applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), wsUrl: withDefault(StringControl, "ws://localhost:3005"), + // ── Room panel allowRoomCreation: withDefault(BoolControl, true), allowRoomSearch: withDefault(BoolControl, true), showRoomPanel: withDefault(BoolControl, true), roomPanelWidth: withDefault(StringControl, "220px"), + // ── LLM settings + systemPrompt: withDefault( + StringControl, + "You are a helpful AI assistant. Answer concisely and clearly.", + ), + llmBotName: withDefault(StringControl, "AI Assistant"), + + // ── Exposed state + llmConversationHistory: arrayObjectExposingStateControl("llmConversationHistory", []), + + // ── Layout / style autoHeight: AutoHeightControl, onEvent: eventHandlerControl(ChatEvents), style: styleControl(TextStyle, "style"), animationStyle: styleControl(AnimationStyle, "animationStyle"), }; -// ─── Property panel ───────────────────────────────────────────────────────── +// ─── Property panel ─────────────────────────────────────────────────────────── const ChatBoxPropertyView = React.memo((props: { children: any }) => { const { children } = props; @@ -71,6 +89,18 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => { {children.roomPanelWidth.propertyView({ label: "Panel Width", tooltip: "e.g. 220px or 25%" })}
+
+ {children.systemPrompt.propertyView({ + label: "System Prompt", + tooltip: + "Prepended to the conversation history sent to your query. Tells the AI how to behave.", + })} + {children.llmBotName.propertyView({ + label: "AI Bot Name", + tooltip: "Display name shown on AI messages in LLM rooms.", + })} +
+ {["logic", "both"].includes(editorMode) && (
{hiddenPropertyView(children)} @@ -97,10 +127,34 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => { ChatBoxPropertyView.displayName = "ChatBoxV2PropertyView"; -// ─── Build component ──────────────────────────────────────────────────────── +// ─── Build component ────────────────────────────────────────────────────────── let ChatBoxV2Tmp = (function () { - return new UICompBuilder(childrenMap, (props) => ) + return new UICompBuilder(childrenMap, (props, dispatch) => { + // Keep a ref to the latest onChange so the callback below never changes + // identity — preventing the infinite re-render loop that occurs when + // calling onChange updates llmConversationHistory, which creates a new + // props.llmConversationHistory reference, which would recreate this + // callback, which would re-trigger the useEffect in ChatBoxView, etc. + const onChangeRef = useRef(props.llmConversationHistory.onChange); + useEffect(() => { + onChangeRef.current = props.llmConversationHistory.onChange; + }); + + const onConversationHistoryChange = useCallback((history: any[]) => { + onChangeRef.current(history); + }, []); + + return ( + + ); + }) .setPropertyViewFn((children) => ) .build(); })(); @@ -111,7 +165,7 @@ ChatBoxV2Tmp = class extends ChatBoxV2Tmp { } }; -// ─── Methods ──────────────────────────────────────────────────────────────── +// ─── Methods ───────────────────────────────────────────────────────────────── ChatBoxV2Tmp = withMethodExposing(ChatBoxV2Tmp, [ { @@ -130,12 +184,13 @@ ChatBoxV2Tmp = withMethodExposing(ChatBoxV2Tmp, [ }, ]); -// ─── Exposing configs ─────────────────────────────────────────────────────── +// ─── Exposing configs ───────────────────────────────────────────────────────── export const ChatBoxV2Comp = withExposingConfigs(ChatBoxV2Tmp, [ new NameConfig("chatName", "Chat display name"), new NameConfig("userId", "Current user ID"), new NameConfig("userName", "Current user name"), new NameConfig("applicationId", "Application scope"), + new NameConfig("llmConversationHistory", "Conversation history for the active LLM room (role + content array)"), NameConfigHidden, ]); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx index 05599d6c6..9a3846900 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -1,13 +1,15 @@ -import React, { useCallback, useState } from "react"; -import { UserOutlined } from "@ant-design/icons"; +import React, { useCallback, useEffect, useState } from "react"; +import { UserOutlined, RobotOutlined } from "@ant-design/icons"; +import { Tag } from "antd"; import { useChatStore } from "../useChatStore"; import { Wrapper, ChatPanelContainer, ChatHeaderBar, ConnectionBanner, ConnectionDot } from "../styles"; import { RoomPanel } from "./RoomPanel"; import { MessageList } from "./MessageList"; import { InputBar } from "./InputBar"; import { CreateRoomModal } from "./CreateRoomModal"; +import { LLM_BOT_AUTHOR_ID } from "../store"; -type ChatBoxEventName = "messageSent" | "messageReceived" | "roomJoined" | "roomLeft"; +type ChatBoxEventName = "messageSent" | "messageReceived" | "roomJoined" | "roomLeft" | "llmMessageReceived"; export interface ChatBoxViewProps { chatName: { value: string }; @@ -19,9 +21,13 @@ export interface ChatBoxViewProps { allowRoomSearch: boolean; showRoomPanel: boolean; roomPanelWidth: string; + systemPrompt: string; + llmBotName: string; style: any; animationStyle: any; onEvent: (event: ChatBoxEventName) => any; + onConversationHistoryChange: (history: any[]) => void; + dispatch?: (...args: any[]) => void; [key: string]: any; } @@ -42,9 +48,13 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { allowRoomSearch, showRoomPanel, roomPanelWidth, + systemPrompt, + llmBotName, style, animationStyle, onEvent, + onConversationHistoryChange, + dispatch, } = props; const chat = useChatStore({ @@ -52,10 +62,44 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { userId: userId.value || "user_1", userName: userName.value || "User", wsUrl: wsUrl || "ws://localhost:3005", + dispatch, + systemPrompt, + llmBotName: llmBotName || "AI Assistant", }); const [createModalOpen, setCreateModalOpen] = useState(false); + const isLlmRoom = chat.currentRoom?.type === "llm"; + + // ── Sync conversation history exposed state for LLM rooms ──────────────── + useEffect(() => { + if (!isLlmRoom) return; + const history = chat.messages + .filter((m) => m.authorType === "user" || m.authorType === "assistant" || m.authorId === LLM_BOT_AUTHOR_ID) + .map((m) => ({ + role: m.authorType === "assistant" || m.authorId === LLM_BOT_AUTHOR_ID ? "assistant" : "user", + content: m.text, + timestamp: m.timestamp, + authorName: m.authorName, + })); + onConversationHistoryChange(history); + }, [chat.messages, isLlmRoom, onConversationHistoryChange]); + + // ── Fire messageReceived event when a new AI message lands ─────────────── + const lastMessageRef = React.useRef(null); + useEffect(() => { + const lastMsg = chat.messages[chat.messages.length - 1]; + if (!lastMsg) return; + if (lastMsg.id === lastMessageRef.current) return; + lastMessageRef.current = lastMsg.id; + + if (lastMsg.authorId === LLM_BOT_AUTHOR_ID || lastMsg.authorType === "assistant") { + onEvent("llmMessageReceived"); + onEvent("messageReceived"); + } + }, [chat.messages, onEvent]); + + // ── Room actions ────────────────────────────────────────────────────────── const handleLeaveRoom = useCallback( async (roomId: string) => { const ok = await chat.leaveRoom(roomId); @@ -72,6 +116,14 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { [chat.joinRoom, onEvent], ); + const handleSend = useCallback( + async (text: string): Promise => { + const ok = await chat.sendMessage(text); + return ok; + }, + [chat.sendMessage], + ); + const status = connectionStatus(chat.ready, chat.connectionLabel); return ( @@ -95,7 +147,18 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => {
-
{chatName.value}
+
+ {chatName.value} + {isLlmRoom && ( + } + color="purple" + style={{ fontSize: 11, borderRadius: 6 }} + > + AI Room + + )} +
{chat.currentRoom?.name || "No room selected"} {chat.currentRoomMembers.length > 0 && ( @@ -104,6 +167,11 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { {chat.currentRoomMembers.length} )} + {isLlmRoom && chat.currentRoom?.llmQueryName && ( + + ↳ {chat.currentRoom.llmQueryName} + + )}
@@ -117,15 +185,20 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { typingUsers={chat.typingUsers} currentUserId={userId.value} ready={chat.ready} + isLlmRoom={isLlmRoom} + isLlmLoading={chat.isLlmLoading} + llmBotName={llmBotName || "AI Assistant"} /> onEvent("messageSent")} + isLlmLoading={chat.isLlmLoading} + isLlmRoom={isLlmRoom} />
diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx index c644af802..51b51239a 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx @@ -1,34 +1,70 @@ -import React, { useCallback } from "react"; -import { Modal, Form, Input, Radio, Button, Space } from "antd"; -import { PlusOutlined, GlobalOutlined, LockOutlined } from "@ant-design/icons"; +import React, { useCallback, useState } from "react"; +import { Modal, Form, Input, Radio, Button, Space, Alert, Segmented } from "antd"; +import { + PlusOutlined, + GlobalOutlined, + LockOutlined, + RobotOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; import type { ChatRoom } from "../store"; export interface CreateRoomModalProps { open: boolean; onClose: () => void; - onCreateRoom: (name: string, type: "public" | "private", description?: string) => Promise; + onCreateRoom: ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ) => Promise; onRoomCreatedEvent: () => void; } +type RoomMode = "normal" | "llm"; + export const CreateRoomModal = React.memo((props: CreateRoomModalProps) => { const { open, onClose, onCreateRoom, onRoomCreatedEvent } = props; const [form] = Form.useForm(); + const [roomMode, setRoomMode] = useState("normal"); + + const handleModeChange = useCallback((val: string | number) => { + setRoomMode(val as RoomMode); + // Reset visibility when switching modes + form.setFieldValue("roomType", val === "llm" ? "llm" : "public"); + }, [form]); const handleFinish = useCallback( - async (values: { roomName: string; roomType: "public" | "private"; description?: string }) => { - const room = await onCreateRoom(values.roomName.trim(), values.roomType, values.description); + async (values: { + roomName: string; + roomType: "public" | "private" | "llm"; + description?: string; + llmQueryName?: string; + }) => { + const type: "public" | "private" | "llm" = + roomMode === "llm" ? "llm" : values.roomType; + + const room = await onCreateRoom( + values.roomName.trim(), + type, + values.description, + roomMode === "llm" ? values.llmQueryName?.trim() : undefined, + ); + if (room) { form.resetFields(); + setRoomMode("normal"); onClose(); onRoomCreatedEvent(); } }, - [onCreateRoom, form, onClose, onRoomCreatedEvent], + [onCreateRoom, form, onClose, onRoomCreatedEvent, roomMode], ); const handleCancel = useCallback(() => { onClose(); form.resetFields(); + setRoomMode("normal"); }, [onClose, form]); return ( @@ -37,10 +73,62 @@ export const CreateRoomModal = React.memo((props: CreateRoomModalProps) => { open={open} onCancel={handleCancel} footer={null} - width={420} + width={460} centered destroyOnHidden > + {/* Room mode selector */} +
+
+ ROOM TYPE +
+ + + Normal Room +
+ ), + value: "normal", + }, + { + label: ( +
+ + AI / LLM Room +
+ ), + value: "llm", + }, + ]} + /> + + + {roomMode === "llm" && ( + } + style={{ + marginBottom: 16, + background: "#faf5ff", + border: "1px solid #e9d5ff", + borderRadius: 8, + }} + message={ + + AI Room — every user message triggers your Lowcoder query. + The AI response is broadcast to all members in real time. + + } + /> + )} +
{ { max: 50, message: "At most 50 characters" }, ]} > - + + - - - - Public - - - Private - - - + + {roomMode === "normal" && ( + + + + Public + + + Private + + + + )} + + {roomMode === "llm" && ( + + Query Name{" "} + + (name of your Lowcoder query) + + + } + rules={[{ required: true, message: "A query name is required for AI rooms" }]} + extra={ + + Create a query in the bottom panel of Lowcoder and enter its exact name here. + Your query will receive{" "} + conversationHistory,{" "} + prompt, and{" "} + roomId as arguments. + + } + > + } + /> + + )} + - diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx index 6d0cd07c7..bb72fe9b1 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx @@ -11,10 +11,13 @@ export interface InputBarProps { onStartTyping: () => void; onStopTyping: () => void; onMessageSentEvent: () => void; + isLlmLoading?: boolean; + isLlmRoom?: boolean; } export const InputBar = React.memo((props: InputBarProps) => { - const { ready, currentRoom, onSend, onStartTyping, onStopTyping, onMessageSentEvent } = props; + const { ready, currentRoom, onSend, onStartTyping, onStopTyping, onMessageSentEvent, isLlmLoading, isLlmRoom } = props; + const isDisabled = !ready || !currentRoom || !!isLlmLoading; const [draft, setDraft] = useState(""); const typingTimeoutRef = useRef | null>(null); const isTypingRef = useRef(false); @@ -83,8 +86,16 @@ export const InputBar = React.memo((props: InputBarProps) => { value={draft} onChange={handleInputChange} onKeyDown={handleKeyDown} - placeholder={ready ? "Type a message..." : "Connecting..."} - disabled={!ready || !currentRoom} + placeholder={ + isLlmLoading + ? "AI is responding..." + : ready + ? isLlmRoom + ? "Ask the AI..." + : "Type a message..." + : "Connecting..." + } + disabled={isDisabled} rows={1} />
+ +
+ {children.pluvPublicKey.propertyView({ + label: "Public Key", + tooltip: "Pluv.io publishable key (pk_...). Can also be set via VITE_PLUV_PUBLIC_KEY env var.", + })} + {children.pluvAuthUrl.propertyView({ + label: "Auth URL", + tooltip: "Pluv auth endpoint URL for token exchange (e.g. /api/auth/pluv or http://localhost:3006/api/auth/pluv)", })}
@@ -92,8 +97,7 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => {
{children.systemPrompt.propertyView({ label: "System Prompt", - tooltip: - "Prepended to the conversation history sent to your query. Tells the AI how to behave.", + tooltip: "Prepended to the conversation history sent to your query. Tells the AI how to behave.", })} {children.llmBotName.propertyView({ label: "AI Bot Name", @@ -127,15 +131,8 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => { ChatBoxPropertyView.displayName = "ChatBoxV2PropertyView"; -// ─── Build component ────────────────────────────────────────────────────────── - let ChatBoxV2Tmp = (function () { return new UICompBuilder(childrenMap, (props, dispatch) => { - // Keep a ref to the latest onChange so the callback below never changes - // identity — preventing the infinite re-render loop that occurs when - // calling onChange updates llmConversationHistory, which creates a new - // props.llmConversationHistory reference, which would recreate this - // callback, which would re-trigger the useEffect in ChatBoxView, etc. const onChangeRef = useRef(props.llmConversationHistory.onChange); useEffect(() => { onChangeRef.current = props.llmConversationHistory.onChange; @@ -145,14 +142,38 @@ let ChatBoxV2Tmp = (function () { onChangeRef.current(history); }, []); + const appId = props.applicationId.value || "lowcoder_app"; + const userId = props.userId.value || "user_1"; + const userName = props.userName.value || "User"; + const roomName = `chatv2_${appId}`; + + // Update the module-level config before pluv connects + pluvConfig.userId = userId; + pluvConfig.userName = userName; + pluvConfig.authUrl = props.pluvAuthUrl || "/api/auth/pluv"; + return ( - + ({ + rooms: t.map("rooms", []), + members: t.map("members", []), + invites: t.map("invites", []), + messages: t.map("messages", []), + })} + onAuthorizationFail={(error: Error) => { + console.error("[PluvChat] Auth failed:", error); + }} + > + + ); }) .setPropertyViewFn((children) => ) @@ -165,8 +186,6 @@ ChatBoxV2Tmp = class extends ChatBoxV2Tmp { } }; -// ─── Methods ───────────────────────────────────────────────────────────────── - ChatBoxV2Tmp = withMethodExposing(ChatBoxV2Tmp, [ { method: { @@ -184,8 +203,6 @@ ChatBoxV2Tmp = withMethodExposing(ChatBoxV2Tmp, [ }, ]); -// ─── Exposing configs ───────────────────────────────────────────────────────── - export const ChatBoxV2Comp = withExposingConfigs(ChatBoxV2Tmp, [ new NameConfig("chatName", "Chat display name"), new NameConfig("userId", "Current user ID"), diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx index 9a3846900..85adf8a00 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -1,12 +1,13 @@ import React, { useCallback, useEffect, useState } from "react"; import { UserOutlined, RobotOutlined } from "@ant-design/icons"; -import { Tag } from "antd"; +import { Tag, Button, Tooltip } from "antd"; import { useChatStore } from "../useChatStore"; import { Wrapper, ChatPanelContainer, ChatHeaderBar, ConnectionBanner, ConnectionDot } from "../styles"; import { RoomPanel } from "./RoomPanel"; import { MessageList } from "./MessageList"; import { InputBar } from "./InputBar"; import { CreateRoomModal } from "./CreateRoomModal"; +import { InviteUserModal } from "./InviteUserModal"; import { LLM_BOT_AUTHOR_ID } from "../store"; type ChatBoxEventName = "messageSent" | "messageReceived" | "roomJoined" | "roomLeft" | "llmMessageReceived"; @@ -16,7 +17,6 @@ export interface ChatBoxViewProps { userId: { value: string }; userName: { value: string }; applicationId: { value: string }; - wsUrl: string; allowRoomCreation: boolean; allowRoomSearch: boolean; showRoomPanel: boolean; @@ -42,8 +42,6 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { chatName, userId, userName, - applicationId, - wsUrl, allowRoomCreation, allowRoomSearch, showRoomPanel, @@ -58,20 +56,19 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { } = props; const chat = useChatStore({ - applicationId: applicationId.value || "lowcoder_app", userId: userId.value || "user_1", userName: userName.value || "User", - wsUrl: wsUrl || "ws://localhost:3005", dispatch, systemPrompt, llmBotName: llmBotName || "AI Assistant", }); const [createModalOpen, setCreateModalOpen] = useState(false); + const [inviteModalOpen, setInviteModalOpen] = useState(false); const isLlmRoom = chat.currentRoom?.type === "llm"; + const isPrivateRoom = chat.currentRoom?.type === "private"; - // ── Sync conversation history exposed state for LLM rooms ──────────────── useEffect(() => { if (!isLlmRoom) return; const history = chat.messages @@ -85,7 +82,6 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { onConversationHistoryChange(history); }, [chat.messages, isLlmRoom, onConversationHistoryChange]); - // ── Fire messageReceived event when a new AI message lands ─────────────── const lastMessageRef = React.useRef(null); useEffect(() => { const lastMsg = chat.messages[chat.messages.length - 1]; @@ -99,18 +95,17 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { } }, [chat.messages, onEvent]); - // ── Room actions ────────────────────────────────────────────────────────── const handleLeaveRoom = useCallback( - async (roomId: string) => { - const ok = await chat.leaveRoom(roomId); + (roomId: string) => { + const ok = chat.leaveRoom(roomId); if (ok) onEvent("roomLeft"); }, [chat.leaveRoom, onEvent], ); const handleJoinRoom = useCallback( - async (roomId: string) => { - const ok = await chat.joinRoom(roomId); + (roomId: string) => { + const ok = chat.joinRoom(roomId); if (ok) onEvent("roomJoined"); }, [chat.joinRoom, onEvent], @@ -118,12 +113,26 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { const handleSend = useCallback( async (text: string): Promise => { - const ok = await chat.sendMessage(text); - return ok; + return chat.sendMessage(text); }, [chat.sendMessage], ); + const handleAcceptInvite = useCallback( + (inviteId: string) => { + const ok = chat.acceptInvite(inviteId); + if (ok) onEvent("roomJoined"); + }, + [chat.acceptInvite, onEvent], + ); + + const handleDeclineInvite = useCallback( + (inviteId: string) => { + chat.declineInvite(inviteId); + }, + [chat.declineInvite], + ); + const status = connectionStatus(chat.ready, chat.connectionLabel); return ( @@ -140,6 +149,9 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { onJoinRoom={handleJoinRoom} onLeaveRoom={handleLeaveRoom} onSearchRooms={chat.searchRooms} + pendingInvites={chat.pendingInvites} + onAcceptInvite={handleAcceptInvite} + onDeclineInvite={handleDeclineInvite} onCreateModalOpen={() => setCreateModalOpen(true)} /> )} @@ -174,10 +186,19 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { )} - - - {chat.ready ? chat.connectionLabel : chat.error || "Connecting..."} - +
+ {isPrivateRoom && ( + + + + )} + + + {chat.ready ? chat.connectionLabel : chat.error || "Connecting..."} + +
{ onCreateRoom={chat.createRoom} onRoomCreatedEvent={() => onEvent("roomJoined")} /> + setInviteModalOpen(false)} + currentRoom={chat.currentRoom} + onSendInvite={chat.sendPrivateInvite} + /> ); }); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InviteUserModal.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InviteUserModal.tsx new file mode 100644 index 000000000..ee0b158bb --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InviteUserModal.tsx @@ -0,0 +1,97 @@ +import React, { useCallback } from "react"; +import { Modal, Form, Input, Button, Space, Alert } from "antd"; +import { UserAddOutlined, LockOutlined } from "@ant-design/icons"; +import type { ChatRoom } from "../store"; + +export interface InviteUserModalProps { + open: boolean; + onClose: () => void; + currentRoom: ChatRoom | null; + onSendInvite: (toUserId: string, toUserName?: string) => Promise; +} + +export const InviteUserModal = React.memo((props: InviteUserModalProps) => { + const { open, onClose, currentRoom, onSendInvite } = props; + const [form] = Form.useForm(); + + const handleSubmit = useCallback(async () => { + const values = await form.validateFields(); + const ok = await onSendInvite( + values.toUserId.trim(), + values.toUserName?.trim() || undefined, + ); + if (ok) { + form.resetFields(); + onClose(); + } + }, [form, onClose, onSendInvite]); + + const handleCancel = useCallback(() => { + form.resetFields(); + onClose(); + }, [form, onClose]); + + const isPrivateRoom = currentRoom?.type === "private"; + + return ( + + {!isPrivateRoom ? ( + + ) : ( + <> + } + style={{ marginBottom: 16 }} + message={ + + Sending invite for {currentRoom.name} + + } + /> + + + } + /> + + + + + + + + + + + + + )} + + ); +}); + +InviteUserModal.displayName = "InviteUserModal"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx index de43df9bd..964a6de43 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx @@ -7,8 +7,10 @@ import { LockOutlined, LogoutOutlined, RobotOutlined, + MailOutlined, } from "@ant-design/icons"; import type { ChatRoom } from "../store"; +import type { PendingRoomInvite } from "../useChatStore"; import { RoomPanelContainer, RoomPanelHeader, @@ -29,6 +31,9 @@ export interface RoomPanelProps { onJoinRoom: (roomId: string) => void; onLeaveRoom: (roomId: string) => void; onSearchRooms: (query: string) => Promise; + pendingInvites: PendingRoomInvite[]; + onAcceptInvite: (inviteId: string) => void; + onDeclineInvite: (inviteId: string) => void; onCreateModalOpen: () => void; } @@ -44,6 +49,9 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { onJoinRoom, onLeaveRoom, onSearchRooms, + pendingInvites, + onAcceptInvite, + onDeclineInvite, onCreateModalOpen, } = props; @@ -128,6 +136,50 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { )} + {!isSearchMode && pendingInvites.length > 0 && ( +
+
+ + Pending Invites ({pendingInvites.length}) +
+ {pendingInvites.map((invite) => ( +
+
+ + {invite.roomName} +
+
+ Invited by {invite.fromUserName} +
+
+ + +
+
+ ))} +
+ )} + {roomListItems.length === 0 && !isSearchMode && ready && (
diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts index 97eb95cca..11777158e 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts @@ -1,502 +1,3 @@ -import * as Y from "yjs"; -import { WebsocketProvider } from "y-websocket"; -import alasql from "alasql"; -import type { - ChatMessage, - ChatRoom, - RoomMember, - TypingUser, - ChangeType, - ChatStoreListener, -} from "./types"; -import { uid, LLM_BOT_AUTHOR_ID } from "./types"; - -const PERSIST_DEBOUNCE_MS = 500; - -/** - * Unified chat store backed by YJS (real-time CRDT sync) and AlaSQL - * (browser-local persistence). On init the AlaSQL data seeds the YJS - * doc so state survives page reloads even without a server. YJS map - * observers write changes back to AlaSQL automatically. - * - * LLM rooms: rooms with type === "llm" store a llmQueryName field. The - * hook layer (useChatStore) is responsible for firing the query and - * writing the AI response back as a message with authorType === "assistant". - * All connected clients see the response via YJS sync automatically. - */ -export class ChatStore { - private ydoc: Y.Doc | null = null; - private wsProvider: WebsocketProvider | null = null; - private messagesMap: Y.Map | null = null; - private roomsMap: Y.Map | null = null; - private membersMap: Y.Map | null = null; - private awarenessHandler: (() => void) | null = null; - - private listeners = new Set(); - private ready = false; - private wsConnected = false; - - private dbName: string; - private dbReady = false; - private persistTimer: ReturnType | null = null; - - private applicationId: string; - private wsUrl: string; - - private static docs = new Map(); - private static providers = new Map(); - private static refCounts = new Map(); - - constructor(applicationId: string, wsUrl: string) { - this.applicationId = applicationId; - this.wsUrl = wsUrl; - this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`; - } - - isReady(): boolean { - return this.ready; - } - - getConnectionLabel(): string { - if (!this.ready) return "Connecting..."; - return this.wsConnected ? "Online" : "Offline (local)"; - } - - async init(): Promise { - if (this.ready) return; - - await this.initDb(); - - const docId = `chatv2_${this.applicationId}`; - let ydoc = ChatStore.docs.get(docId); - let isNewDoc = false; - - if (!ydoc) { - ydoc = new Y.Doc(); - ChatStore.docs.set(docId, ydoc); - ChatStore.refCounts.set(docId, 1); - isNewDoc = true; - console.log(`[YJS] Created new document: ${docId}`); - } else { - ChatStore.refCounts.set( - docId, - (ChatStore.refCounts.get(docId) || 0) + 1, - ); - console.log(`[YJS] Reusing existing document: ${docId}`); - } - - this.ydoc = ydoc; - this.messagesMap = ydoc.getMap("messages"); - this.roomsMap = ydoc.getMap("rooms"); - this.membersMap = ydoc.getMap("members"); - - if (isNewDoc) { - await this.hydrateFromDb(); - } - - let wsProvider = ChatStore.providers.get(docId); - if (!wsProvider) { - console.log(`[YJS] Creating WebSocket provider for ${docId} at ${this.wsUrl}`); - wsProvider = new WebsocketProvider(this.wsUrl, docId, ydoc, { - connect: true, - }); - ChatStore.providers.set(docId, wsProvider); - } - this.wsProvider = wsProvider; - - this.messagesMap.observe(() => { - this.schedulePersist(); - this.notify(new Set(["messages"])); - }); - this.roomsMap.observe(() => { - this.schedulePersist(); - this.notify(new Set(["rooms"])); - }); - this.membersMap.observe(() => { - this.schedulePersist(); - this.notify(new Set(["members"])); - }); - - const awarenessHandler = () => this.notify(new Set(["typing"])); - wsProvider.awareness.on("change", awarenessHandler); - this.awarenessHandler = awarenessHandler; - - wsProvider.on("status", (e: { status: string }) => { - this.wsConnected = e.status === "connected"; - this.notify(new Set(["connection"])); - }); - this.wsConnected = wsProvider.wsconnected; - - this.ready = true; - console.log(`[YJS] ChatStore initialized for ${this.applicationId} (${this.wsConnected ? "online" : "offline"})`); - this.notify(new Set(["connection"])); - } - - destroy(): void { - if (this.persistTimer) { - clearTimeout(this.persistTimer); - this.persistTimer = null; - } - - this.persistToDb(); - - if (this.ydoc) { - const docId = `chatv2_${this.applicationId}`; - const count = (ChatStore.refCounts.get(docId) || 1) - 1; - if (count <= 0) { - ChatStore.providers.get(docId)?.destroy(); - ChatStore.providers.delete(docId); - ChatStore.docs.delete(docId); - ChatStore.refCounts.delete(docId); - } else { - ChatStore.refCounts.set(docId, count); - } - } - - if (this.wsProvider && this.awarenessHandler) { - this.wsProvider.awareness.setLocalStateField("typing", null); - this.wsProvider.awareness.off("change", this.awarenessHandler); - this.awarenessHandler = null; - } - - this.ydoc = null; - this.wsProvider = null; - this.messagesMap = null; - this.roomsMap = null; - this.membersMap = null; - this.listeners.clear(); - this.ready = false; - } - - subscribe(listener: ChatStoreListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(changes: Set): void { - this.listeners.forEach((fn) => fn(changes)); - } - - // ── AlaSQL persistence ───────────────────────────────────────────────── - - private async initDb(): Promise { - alasql.options.autocommit = true; - await alasql.promise( - `CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${this.dbName}`, - ); - await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${this.dbName}`); - await alasql.promise(`USE ${this.dbName}`); - - await alasql.promise(` - CREATE TABLE IF NOT EXISTS rooms ( - id STRING PRIMARY KEY, name STRING, description STRING, - type STRING, llmQueryName STRING, - creatorId STRING, createdAt NUMBER, updatedAt NUMBER - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS messages ( - id STRING PRIMARY KEY, roomId STRING, authorId STRING, - authorName STRING, text STRING, timestamp NUMBER, authorType STRING - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS members ( - roomId STRING, userId STRING, userName STRING, joinedAt NUMBER - ) - `); - - // Schema migration: add new columns to pre-existing tables that may - // not have them. AlaSQL throws if the column already exists — that's fine. - try { await alasql.promise(`ALTER TABLE rooms ADD COLUMN llmQueryName STRING`); } catch { /* already exists */ } - try { await alasql.promise(`ALTER TABLE messages ADD COLUMN authorType STRING`); } catch { /* already exists */ } - - this.dbReady = true; - } - - private async hydrateFromDb(): Promise { - if (!this.dbReady) return; - - const rooms = (await alasql.promise(`SELECT * FROM rooms`)) as ChatRoom[]; - for (const r of rooms) { - if (!this.roomsMap!.has(r.id)) this.roomsMap!.set(r.id, r); - } - - const messages = (await alasql.promise(`SELECT * FROM messages`)) as ChatMessage[]; - for (const m of messages) { - if (!this.messagesMap!.has(m.id)) this.messagesMap!.set(m.id, m); - } - - const members = (await alasql.promise(`SELECT * FROM members`)) as RoomMember[]; - for (const m of members) { - const key = `${m.roomId}::${m.userId}`; - if (!this.membersMap!.has(key)) this.membersMap!.set(key, m); - } - } - - private schedulePersist(): void { - if (this.persistTimer) return; - this.persistTimer = setTimeout(() => { - this.persistTimer = null; - this.persistToDb(); - }, PERSIST_DEBOUNCE_MS); - } - - private persistToDb(): void { - if (!this.dbReady) return; - try { - alasql(`DELETE FROM rooms`); - this.roomsMap?.forEach((v) => { - const r = v as ChatRoom; - alasql(`INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ - r.id, r.name, r.description, r.type, r.llmQueryName ?? null, - r.creatorId, r.createdAt, r.updatedAt, - ]); - }); - - alasql(`DELETE FROM messages`); - this.messagesMap?.forEach((v) => { - const m = v as ChatMessage; - alasql(`INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)`, [ - m.id, m.roomId, m.authorId, m.authorName, m.text, m.timestamp, - m.authorType ?? null, - ]); - }); - - alasql(`DELETE FROM members`); - this.membersMap?.forEach((v: any) => { - alasql(`INSERT INTO members VALUES (?, ?, ?, ?)`, [ - v.roomId, v.userId, v.userName, v.joinedAt, - ]); - }); - } catch { - /* persistence is best-effort */ - } - } - - // ── Rooms ────────────────────────────────────────────────────────────── - - async createRoom( - name: string, - type: "public" | "private" | "llm", - creatorId: string, - creatorName: string, - description = "", - llmQueryName?: string, - id?: string, - ): Promise { - this.assert(); - const roomId = id ?? uid(); - const now = Date.now(); - const room: ChatRoom = { - id: roomId, name, description, type, - llmQueryName: type === "llm" ? (llmQueryName ?? "") : undefined, - creatorId, createdAt: now, updatedAt: now, - }; - console.log(`[YJS] Creating room: ${name} (${roomId}) type=${type}`); - this.ydoc!.transact(() => { - this.roomsMap!.set(roomId, room); - this.membersMap!.set(`${roomId}::${creatorId}`, { - roomId, userId: creatorId, userName: creatorName, joinedAt: now, - } as RoomMember); - }); - return room; - } - - async getRoom(roomId: string): Promise { - this.assert(); - return (this.roomsMap!.get(roomId) as ChatRoom) ?? null; - } - - async getRoomByName(name: string): Promise { - this.assert(); - for (const room of this.roomsMap!.values()) { - if ((room as ChatRoom).name === name) return room as ChatRoom; - } - return null; - } - - async getAllRooms(): Promise { - this.assert(); - const rooms: ChatRoom[] = []; - this.roomsMap!.forEach((v) => rooms.push(v as ChatRoom)); - rooms.sort((a, b) => b.updatedAt - a.updatedAt); - return rooms; - } - - async getUserRooms(userId: string): Promise { - this.assert(); - const memberRoomIds = new Set(); - this.membersMap!.forEach((v: any) => { - if (v.userId === userId) memberRoomIds.add(v.roomId); - }); - const rooms: ChatRoom[] = []; - this.roomsMap!.forEach((v) => { - const r = v as ChatRoom; - if (memberRoomIds.has(r.id)) rooms.push(r); - }); - rooms.sort((a, b) => b.updatedAt - a.updatedAt); - return rooms; - } - - async getSearchableRooms(userId: string, query: string): Promise { - this.assert(); - const memberRoomIds = new Set(); - this.membersMap!.forEach((v: any) => { - if (v.userId === userId) memberRoomIds.add(v.roomId); - }); - const lq = query.toLowerCase(); - const rooms: ChatRoom[] = []; - this.roomsMap!.forEach((v) => { - const r = v as ChatRoom; - // Only public and llm rooms are discoverable via search - if (r.type === "private") return; - if (memberRoomIds.has(r.id)) return; - if ( - r.name.toLowerCase().includes(lq) || - r.description.toLowerCase().includes(lq) - ) { - rooms.push(r); - } - }); - rooms.sort((a, b) => b.updatedAt - a.updatedAt); - return rooms; - } - - async ensureRoom( - name: string, - type: "public" | "private" | "llm", - creatorId: string, - creatorName: string, - llmQueryName?: string, - ): Promise { - let room = await this.getRoomByName(name); - if (!room) room = await this.createRoom(name, type, creatorId, creatorName, "", llmQueryName); - if (!(await this.isMember(room.id, creatorId))) - await this.joinRoom(room.id, creatorId, creatorName); - return room; - } - - // ── Membership ────────────────────────────────────────────────────────── - - async joinRoom(roomId: string, userId: string, userName: string): Promise { - this.assert(); - const key = `${roomId}::${userId}`; - if (this.membersMap!.has(key)) return true; - console.log(`[YJS] User ${userName} (${userId}) joining room ${roomId}`); - this.membersMap!.set(key, { - roomId, userId, userName, joinedAt: Date.now(), - } as RoomMember); - return true; - } - - async leaveRoom(roomId: string, userId: string): Promise { - this.assert(); - this.membersMap!.delete(`${roomId}::${userId}`); - return true; - } - - async getRoomMembers(roomId: string): Promise { - this.assert(); - const members: RoomMember[] = []; - this.membersMap!.forEach((v: any) => { - if (v.roomId === roomId) members.push(v as RoomMember); - }); - members.sort((a, b) => a.joinedAt - b.joinedAt); - return members; - } - - async isMember(roomId: string, userId: string): Promise { - this.assert(); - return this.membersMap!.has(`${roomId}::${userId}`); - } - - // ── Messages ─────────────────────────────────────────────────────────── - - async sendMessage( - roomId: string, - authorId: string, - authorName: string, - text: string, - authorType: "user" | "assistant" = "user", - id?: string, - ): Promise { - this.assert(); - const msg: ChatMessage = { - id: id ?? uid(), - roomId, - authorId, - authorName, - text, - timestamp: Date.now(), - authorType, - }; - console.log(`[YJS] ${authorType === "assistant" ? "[AI]" : "[User]"} → room ${roomId}: "${text.substring(0, 50)}${text.length > 50 ? "..." : ""}"`); - this.ydoc!.transact(() => { - this.messagesMap!.set(msg.id, msg); - const room = this.roomsMap!.get(roomId) as ChatRoom | undefined; - if (room) { - this.roomsMap!.set(roomId, { ...room, updatedAt: msg.timestamp }); - } - }); - return msg; - } - - async getMessages(roomId: string, limit = 100): Promise { - this.assert(); - const msgs: ChatMessage[] = []; - this.messagesMap!.forEach((v) => { - const m = v as ChatMessage; - if (m.roomId === roomId) msgs.push(m); - }); - msgs.sort((a, b) => a.timestamp - b.timestamp); - return msgs.slice(-limit); - } - - /** - * Returns the conversation history for an LLM room in the standard - * { role, content } format suitable for passing to AI APIs. - * The system prompt (if any) is NOT prepended here — the hook layer adds it. - */ - async getLlmConversationHistory(roomId: string): Promise> { - const messages = await this.getMessages(roomId); - return messages - .filter((m) => m.authorId !== LLM_BOT_AUTHOR_ID || m.authorType === "assistant") - .map((m) => ({ - role: (m.authorType === "assistant" ? "assistant" : "user") as "user" | "assistant", - content: m.text, - })); - } - - // ── Typing (via Awareness — ephemeral, auto-clears on disconnect) ────── - - startTyping(roomId: string, userId: string, userName: string): void { - this.wsProvider?.awareness.setLocalStateField("typing", { userId, userName, roomId }); - } - - stopTyping(_roomId: string, _userId: string): void { - this.wsProvider?.awareness.setLocalStateField("typing", null); - } - - getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { - if (!this.wsProvider) return []; - const myClientId = this.wsProvider.awareness.clientID; - const result: TypingUser[] = []; - this.wsProvider.awareness.getStates().forEach((state, clientId) => { - if (clientId === myClientId) return; - const typing = state.typing as TypingUser | null | undefined; - if (!typing) return; - if (typing.roomId !== roomId) return; - if (excludeUserId && typing.userId === excludeUserId) return; - result.push(typing); - }); - return result; - } - - // ── Internal ──────────────────────────────────────────────────────────── - - private assert(): void { - if (!this.ready) - throw new Error("ChatStore not initialized. Call init() first."); - } -} +// ChatStore class has been replaced by pluv.io integration. +// All storage, sync, and presence are now managed via @pluv/client + @pluv/react. +// See pluvClient.ts for the pluv setup and ../useChatStore.ts for the React hook. diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts index f6f15fd31..6bbf4898f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts @@ -5,7 +5,6 @@ export interface ChatMessage { authorName: string; text: string; timestamp: number; - /** Distinguishes human messages from LLM responses. Absent on legacy messages → treated as "user". */ authorType?: "user" | "assistant"; } @@ -13,9 +12,7 @@ export interface ChatRoom { id: string; name: string; description: string; - /** "llm" rooms broadcast AI responses to every member via YJS. */ type: "public" | "private" | "llm"; - /** Name of the Lowcoder query that handles LLM calls (only for type === "llm"). */ llmQueryName?: string; creatorId: string; createdAt: number; @@ -29,17 +26,24 @@ export interface RoomMember { joinedAt: number; } +export interface RoomInvite { + id: string; + roomId: string; + fromUserId: string; + fromUserName: string; + toUserId: string; + toUserName?: string; + status: "pending" | "accepted" | "declined"; + createdAt: number; + respondedAt?: number; +} + export interface TypingUser { userId: string; userName: string; roomId: string; } -export type ChangeType = "rooms" | "messages" | "members" | "typing" | "connection"; - -export type ChatStoreListener = (changes: Set) => void; - -/** Fixed authorId used for all AI-generated messages so they are identifiable across rooms. */ export const LLM_BOT_AUTHOR_ID = "__llm_bot__"; export function uid(): string { diff --git a/client/yarn.lock b/client/yarn.lock index 7f48f5878..35dada284 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3354,6 +3354,13 @@ __metadata: languageName: node linkType: hard +"@panva/hkdf@npm:^1.2.1": + version: 1.2.1 + resolution: "@panva/hkdf@npm:1.2.1" + checksum: a4a9d1812f88f02bc163b365524bbaa5239cc4711e5e7be1bda68dabae1c896cf1cd12520949b0925a6910733d1afcb25ab51fd3cf06f0f69aee988fffebf56e + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -3361,6 +3368,94 @@ __metadata: languageName: node linkType: hard +"@pluv/client@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/client@npm:4.0.1" + dependencies: + "@pluv/crdt": ^4.0.1 + "@pluv/types": ^4.0.1 + wonka: ^6.3.5 + checksum: b8a56e5a19f5fd0e7f78517b1db39cf9b88add793d71b52e4c9307d3a3ff68e2e55a437d134e0877effd0d2d765b0d919878b1a42a5fecc892f0278d2eaf6ce4 + languageName: node + linkType: hard + +"@pluv/crdt-yjs@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/crdt-yjs@npm:4.0.1" + dependencies: + "@pluv/crdt": ^4.0.1 + "@pluv/types": ^4.0.1 + "@types/node": ^25.0.9 + js-base64: ^3.7.8 + lib0: ^0.2.117 + peerDependencies: + yjs: ^13.0.0 + checksum: 286c302d5522a0f7e94e0d69fa2e9ae27029a70e6883ecc20ec2183c8e75c8096d7aa9d7706a5268426f79dc048b99737fdeab03c5700ef21923314550dbc8e5 + languageName: node + linkType: hard + +"@pluv/crdt@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/crdt@npm:4.0.1" + dependencies: + "@pluv/types": ^4.0.1 + checksum: 1cf7e0fbe22fa88eb42599ec35732e680a1981256684898c528ae76f4eaacee160c9ac62fe0189654f9f06a5a5c57011de32880984948800c28e434b57929a22 + languageName: node + linkType: hard + +"@pluv/io@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/io@npm:4.0.1" + dependencies: + "@panva/hkdf": ^1.2.1 + "@pluv/crdt": ^4.0.1 + "@pluv/types": ^4.0.1 + jose: ^6.1.3 + kleur: ^4.1.5 + wonka: ^6.3.5 + checksum: eefba25d9340a82fc31e41b2cc72ea1d4e97e3810e7ddb3d44d524e850fcb6d21ddb682bcfc68a241208b0b19ade9b03eb9f61986b97f5eb022cbe41c30ca3e0 + languageName: node + linkType: hard + +"@pluv/platform-pluv@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/platform-pluv@npm:4.0.1" + dependencies: + "@pluv/crdt": ^4.0.1 + "@pluv/io": ^4.0.1 + "@pluv/types": ^4.0.1 + "@types/node": ^25.0.9 + fast-json-stable-stringify: ^2.1.0 + hono: ^4.11.4 + zod: ^4.3.5 + checksum: 6c6c05d07e5a01063712f479f984ad2a04531650950ddca89dd433897c0f22460fea7e8f12fd67a78edb5078afb8ca5b8da18a43f1e4005744677cd767891cd0 + languageName: node + linkType: hard + +"@pluv/react@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/react@npm:4.0.1" + dependencies: + "@pluv/client": ^4.0.1 + "@pluv/crdt": ^4.0.1 + "@pluv/types": ^4.0.1 + fast-deep-equal: ^3.1.3 + peerDependencies: + "@types/react": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + checksum: 16109d29536448c25ef8b2847acb51f29883ac6f3c8f9f49086d582613b2c6ebeda464380af30c1ea6c1e780643e5251f16cf966f60214d88ae4dfd291367665 + languageName: node + linkType: hard + +"@pluv/types@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/types@npm:4.0.1" + dependencies: + wonka: ^6.3.5 + checksum: c52f30d124a236cefd4d91564e7920661fcd6e3f6ce49803d169be0bf5fb5044039deaca024854e65ced51b589d7f06a57437eb169650fa0a474734bfc7b061e + languageName: node + linkType: hard + "@polka/url@npm:^1.0.0-next.24": version: 1.0.0-next.29 resolution: "@polka/url@npm:1.0.0-next.29" @@ -5560,6 +5655,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^25.0.9": + version: 25.3.3 + resolution: "@types/node@npm:25.3.3" + dependencies: + undici-types: ~7.18.0 + checksum: 9186aae36f8ddb0b3630dba446e5c16e5f3e6c5e7a4708d117a394d3e3b6f41db2dd83a6127adf4567826776a732ca9e2561594667bce74bb18ea4d59ee1e06a + languageName: node + linkType: hard + "@types/papaparse@npm:^5.3.5": version: 5.3.16 resolution: "@types/papaparse@npm:5.3.16" @@ -6430,6 +6534,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:^2.0.0": + version: 2.0.0 + resolution: "accepts@npm:2.0.0" + dependencies: + mime-types: ^3.0.0 + negotiator: ^1.0.0 + checksum: 49fe6c050cb6f6ff4e771b4d88324fca4d3127865f2473872e818dca127d809ba3aa8fdfc7acb51dd3c5bade7311ca6b8cfff7015ea6db2f7eb9c8444d223a4f + languageName: node + linkType: hard + "accepts@npm:~1.3.4, accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" @@ -7585,6 +7699,23 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^2.2.1": + version: 2.2.2 + resolution: "body-parser@npm:2.2.2" + dependencies: + bytes: ^3.1.2 + content-type: ^1.0.5 + debug: ^4.4.3 + http-errors: ^2.0.0 + iconv-lite: ^0.7.0 + on-finished: ^2.4.1 + qs: ^6.14.1 + raw-body: ^3.0.1 + type-is: ^2.0.1 + checksum: 0b8764065ff2a8c7cf3c905193b5b528d6ab5246f0df4c743c0e887d880abcc336dad5ba86d959d7efee6243a49c2c2e5b0cee43f0ccb7d728f5496c97537a90 + languageName: node + linkType: hard + "bonjour-service@npm:^1.2.1": version: 1.3.0 resolution: "bonjour-service@npm:1.3.0" @@ -7826,7 +7957,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e @@ -8462,7 +8593,14 @@ __metadata: languageName: node linkType: hard -"content-type@npm:~1.0.4, content-type@npm:~1.0.5": +"content-disposition@npm:^1.0.0": + version: 1.0.1 + resolution: "content-disposition@npm:1.0.1" + checksum: f1ee5363968e7e4c491fcd9796d3c489ab29c4ea0bfa5dcc3379a9833d6044838367cf8a11c90b179cb2a8d471279ab259119c52e0d3e4ed30934ccd56b6d694 + languageName: node + linkType: hard + +"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766 @@ -8483,6 +8621,13 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:^1.2.1": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 1ad4f9b3907c9f3673a0f0a07c0a23da7909ac6c9204c5d80a0ec102fe50ccc45f27fdf496361840d6c132c5bb0037122c0a381f856d070183d1ebe3e5e041ff + languageName: node + linkType: hard + "cookie@npm:0.7.1": version: 0.7.1 resolution: "cookie@npm:0.7.1" @@ -8490,6 +8635,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^0.7.1": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 9bf8555e33530affd571ea37b615ccad9b9a34febbf2c950c86787088eb00a8973690833b0f8ebd6b69b753c62669ea60cec89178c1fb007bf0749abed74f93e + languageName: node + linkType: hard + coolshapes-react@lowcoder-org/coolshapes-react: version: 1.0.1 resolution: "coolshapes-react@https://github.com/lowcoder-org/coolshapes-react.git#commit=0530e0e01feeba965286c1321f9c1cacb47bf587" @@ -8564,6 +8716,16 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"cors@npm:^2.8.6": + version: 2.8.6 + resolution: "cors@npm:2.8.6" + dependencies: + object-assign: ^4 + vary: ^1 + checksum: a967922b00fd17d836d21308c66ab9081d6c0f7dc019486ba1643a58281b12fc27d8c260471ddca72874b5bfe17a2d471ff8762d34f6009022ff749ec1136220 + languageName: node + linkType: hard + "cose-base@npm:^1.0.0": version: 1.0.3 resolution: "cose-base@npm:1.0.3" @@ -9482,6 +9644,18 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"debug@npm:^4.4.0, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: ^2.1.3 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 4805abd570e601acdca85b6aa3757186084a45cff9b2fa6eee1f3b173caa776b45f478b2a71a572d616d2010cea9211d0ac4a02a610e4c18ac4324bde3760834 + languageName: node + linkType: hard + "decimal.js@npm:^10.4.2, decimal.js@npm:^10.4.3": version: 10.5.0 resolution: "decimal.js@npm:10.5.0" @@ -9645,7 +9819,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"depd@npm:2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a @@ -10105,6 +10279,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"encodeurl@npm:^2.0.0, encodeurl@npm:~2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe + languageName: node + linkType: hard + "encodeurl@npm:~1.0.2": version: 1.0.2 resolution: "encodeurl@npm:1.0.2" @@ -10112,13 +10293,6 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"encodeurl@npm:~2.0.0": - version: 2.0.0 - resolution: "encodeurl@npm:2.0.0" - checksum: abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe - languageName: node - linkType: hard - "encoding-down@npm:^6.3.0": version: 6.3.0 resolution: "encoding-down@npm:6.3.0" @@ -10481,7 +10655,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"escape-html@npm:~1.0.3": +"escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" checksum: 6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24 @@ -10937,7 +11111,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"etag@npm:~1.8.1": +"etag@npm:^1.8.1, etag@npm:~1.8.1": version: 1.8.1 resolution: "etag@npm:1.8.1" checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff @@ -11076,6 +11250,42 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"express@npm:^5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" + dependencies: + accepts: ^2.0.0 + body-parser: ^2.2.1 + content-disposition: ^1.0.0 + content-type: ^1.0.5 + cookie: ^0.7.1 + cookie-signature: ^1.2.1 + debug: ^4.4.0 + depd: ^2.0.0 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + etag: ^1.8.1 + finalhandler: ^2.1.0 + fresh: ^2.0.0 + http-errors: ^2.0.0 + merge-descriptors: ^2.0.0 + mime-types: ^3.0.0 + on-finished: ^2.4.1 + once: ^1.4.0 + parseurl: ^1.3.3 + proxy-addr: ^2.0.7 + qs: ^6.14.0 + range-parser: ^1.2.1 + router: ^2.2.0 + send: ^1.1.0 + serve-static: ^2.2.0 + statuses: ^2.0.1 + type-is: ^2.0.1 + vary: ^1.1.2 + checksum: e0bc9c11fcf4e6ed29c9b0551229e8cf35d959970eb5e10ef3e48763eb3a63487251950d9bf4ef38b93085f0f33bb1fc37ab07349b8fa98a0fa5f67236d4c054 + languageName: node + linkType: hard + "extend@npm:^3.0.0, extend@npm:~3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -11329,6 +11539,20 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"finalhandler@npm:^2.1.0": + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" + dependencies: + debug: ^4.4.0 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + on-finished: ^2.4.1 + parseurl: ^1.3.3 + statuses: ^2.0.1 + checksum: e5303c4cccce46019cf0f59b07a36cc6d37549f1efe2111c16cd78e6e500d3bfd68d3b45044c9a67a0c75ad3128ee1106fae9a0152ca3c0a8ee3bf3a4a1464bb + languageName: node + linkType: hard + "find-cache-dir@npm:^4.0.0": version: 4.0.0 resolution: "find-cache-dir@npm:4.0.0" @@ -11511,6 +11735,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 38b9828352c6271e2a0dd8bdd985d0100dbbc4eb8b6a03286071dd6f7d96cfaacd06d7735701ad9a95870eb3f4555e67c08db1dcfe24c2e7bb87383c72fae1d2 + languageName: node + linkType: hard + "fs-extra@npm:^10.0.1, fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -12219,6 +12450,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.12.3 + resolution: "hono@npm:4.12.3" + checksum: ebe122249ef71d32d0ed769338d2abef2e712a4e2ea4cbe9d0c1c7148febdd67c02315b694938d0b95f68a33b4b0d02fbbed50d8573d8f0a847df6a3d0493373 + languageName: node + linkType: hard + "hotkeys-js@npm:^3.8.7": version: 3.13.10 resolution: "hotkeys-js@npm:3.13.10" @@ -12338,6 +12576,19 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: ~2.0.0 + inherits: ~2.0.4 + setprototypeof: ~1.2.0 + statuses: ~2.0.2 + toidentifier: ~1.0.1 + checksum: 155d1a100a06e4964597013109590b97540a177b69c3600bbc93efc746465a99a2b718f43cdf76b3791af994bbe3a5711002046bf668cdc007ea44cea6df7ccd + languageName: node + linkType: hard + "http-errors@npm:~1.6.2": version: 1.6.3 resolution: "http-errors@npm:1.6.3" @@ -12500,6 +12751,15 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0": + version: 0.7.2 + resolution: "iconv-lite@npm:0.7.2" + dependencies: + safer-buffer: ">= 2.1.2 < 3.0.0" + checksum: faf884c1f631a5d676e3e64054bed891c7c5f616b790082d99ccfbfd017c661a39db8009160268fd65fae57c9154d4d491ebc9c301f3446a078460ef114dc4b8 + languageName: node + linkType: hard + "icss-utils@npm:^5.0.0, icss-utils@npm:^5.1.0": version: 5.1.0 resolution: "icss-utils@npm:5.1.0" @@ -13071,6 +13331,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"is-promise@npm:^4.0.0": + version: 4.0.0 + resolution: "is-promise@npm:4.0.0" + checksum: 0b46517ad47b00b6358fd6553c83ec1f6ba9acd7ffb3d30a0bf519c5c69e7147c132430452351b8a9fc198f8dd6c4f76f8e6f5a7f100f8c77d57d9e0f4261a8a + languageName: node + linkType: hard + "is-reference@npm:1.2.1, is-reference@npm:^1.2.1": version: 1.2.1 resolution: "is-reference@npm:1.2.1" @@ -13920,6 +14187,20 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.1.3 + resolution: "jose@npm:6.1.3" + checksum: 7f51c7e77f82b70ef88ede9fd1760298bc0ffbf143b9d94f78c08462987ae61864535c1856bc6c26d335f857c7d41f4fffcc29134212c19ea929ce34a4c790f0 + languageName: node + linkType: hard + +"js-base64@npm:^3.7.8": + version: 3.7.8 + resolution: "js-base64@npm:3.7.8" + checksum: 891746b0f23aea7dd466c5ef2d349b093944a25eca6093c09b2cbb99bc47a94237c63b91623bbc203306b7c72aab5112e90378544bceef3fd0eb9ab86d7af496 + languageName: node + linkType: hard + "js-cookie@npm:^2.2.1": version: 2.2.1 resolution: "js-cookie@npm:2.2.1" @@ -14277,7 +14558,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"kleur@npm:^4.0.3": +"kleur@npm:^4.0.3, kleur@npm:^4.1.5": version: 4.1.5 resolution: "kleur@npm:4.1.5" checksum: 1dc476e32741acf0b1b5b0627ffd0d722e342c1b0da14de3e8ae97821327ca08f9fb944542fb3c126d90ac5f27f9d804edbe7c585bf7d12ef495d115e0f22c12 @@ -14614,6 +14895,19 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"lib0@npm:^0.2.117, lib0@npm:^0.2.74": + version: 0.2.117 + resolution: "lib0@npm:0.2.117" + dependencies: + isomorphic.js: ^0.2.4 + bin: + 0ecdsa-generate-keypair: bin/0ecdsa-generate-keypair.js + 0gentesthtml: bin/gentesthtml.js + 0serve: bin/0serve.js + checksum: 948a6bb292cc643bcaea948b82f72a05edb83ff172803ba0ebdbf87361f6446d2877b61611f20ccd377c7bfa0453925b27ea75db8b694abab84216c6ca50325c + languageName: node + linkType: hard + "lilconfig@npm:2.1.0": version: 2.1.0 resolution: "lilconfig@npm:2.1.0" @@ -15154,6 +15448,11 @@ coolshapes-react@lowcoder-org/coolshapes-react: "@jsonforms/core": ^3.5.1 "@lottiefiles/dotlottie-react": ^0.13.0 "@manaflair/redux-batch": ^1.0.0 + "@pluv/client": ^4.0.1 + "@pluv/crdt-yjs": ^4.0.1 + "@pluv/io": ^4.0.1 + "@pluv/platform-pluv": ^4.0.1 + "@pluv/react": ^4.0.1 "@radix-ui/react-avatar": ^1.1.10 "@radix-ui/react-dialog": ^1.1.14 "@radix-ui/react-slot": ^1.2.3 @@ -15189,6 +15488,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: coolshapes-react: lowcoder-org/coolshapes-react copy-to-clipboard: ^3.3.3 core-js: ^3.25.2 + cors: ^2.8.6 dayjs: ^1.11.13 dotenv: ^16.0.3 echarts: ^5.4.3 @@ -15198,6 +15498,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: eslint-config-react-app: ^7.0.1 eslint-plugin-only-ascii: ^0.0.0 eslint4b-prebuilt-2: ^7.32.0 + express: ^5.2.1 file-saver: ^2.0.5 github-markdown-css: ^5.1.0 hotkeys-js: ^3.8.7 @@ -15270,9 +15571,11 @@ coolshapes-react@lowcoder-org/coolshapes-react: web-vitals: ^2.1.0 ws: ^8.18.3 xlsx: ^0.18.5 + y-indexeddb: ^9.0.12 y-protocols: ^1.0.6 y-websocket: ^3.0.0 yjs: ^13.6.27 + zod: ^4.3.6 languageName: unknown linkType: soft @@ -15742,6 +16045,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"media-typer@npm:^1.1.0": + version: 1.1.0 + resolution: "media-typer@npm:1.1.0" + checksum: a58dd60804df73c672942a7253ccc06815612326dc1c0827984b1a21704466d7cde351394f47649e56cf7415e6ee2e26e000e81b51b3eebb5a93540e8bf93cbd + languageName: node + linkType: hard + "memfs@npm:^4.6.0": version: 4.17.2 resolution: "memfs@npm:4.17.2" @@ -15778,6 +16088,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: e383332e700a94682d0125a36c8be761142a1320fc9feeb18e6e36647c9edf064271645f5669b2c21cf352116e561914fd8aa831b651f34db15ef4038c86696a + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -16431,7 +16748,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"mime-db@npm:>= 1.43.0 < 2": +"mime-db@npm:>= 1.43.0 < 2, mime-db@npm:^1.54.0": version: 1.54.0 resolution: "mime-db@npm:1.54.0" checksum: e99aaf2f23f5bd607deb08c83faba5dd25cf2fec90a7cc5b92d8260867ee08dab65312e1a589e60093dc7796d41e5fae013268418482f1db4c7d52d0a0960ac9 @@ -16447,6 +16764,15 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": + version: 3.0.2 + resolution: "mime-types@npm:3.0.2" + dependencies: + mime-db: ^1.54.0 + checksum: 70b74794f408419e4b6a8e3c93ccbed79b6a6053973a3957c5cc04ff4ad8d259f0267da179e3ecae34c3edfb4bfd7528db23a101e32d21ad8e196178c8b7b75a + languageName: node + linkType: hard + "mime@npm:1.6.0, mime@npm:^1.4.1": version: 1.6.0 resolution: "mime@npm:1.6.0" @@ -17024,7 +17350,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": +"object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f @@ -17164,7 +17490,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"once@npm:^1.3.0": +"once@npm:^1.3.0, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -17473,7 +17799,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": +"parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 @@ -17565,6 +17891,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"path-to-regexp@npm:^8.0.0": + version: 8.3.0 + resolution: "path-to-regexp@npm:8.3.0" + checksum: 73e0d3db449f9899692b10be8480bbcfa294fd575be2d09bce3e63f2f708d1fccd3aaa8591709f8b82062c528df116e118ff9df8f5c52ccc4c2443a90be73e10 + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -17917,7 +18250,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"proxy-addr@npm:~2.0.7": +"proxy-addr@npm:^2.0.7, proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" dependencies: @@ -18026,6 +18359,15 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"qs@npm:^6.14.0, qs@npm:^6.14.1": + version: 6.15.0 + resolution: "qs@npm:6.15.0" + dependencies: + side-channel: ^1.1.0 + checksum: 65e797e3747fa1092e062da7b3e0684a9194e07ccab3a9467d416d2579d2feab0adf3aa4b94446e9f69ba7426589a8728f78a10a549308c97563a79d1c0d8595 + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3" @@ -18115,6 +18457,18 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"raw-body@npm:^3.0.1": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: ~3.1.2 + http-errors: ~2.0.1 + iconv-lite: ~0.7.0 + unpipe: ~1.0.0 + checksum: bf8ce8e9734f273f24d81f9fed35609dbd25c2869faa5fb5075f7ee225c0913e2240adda03759d7e72f2a757f8012d58bb7a871a80261d5140ad65844caeb5bd + languageName: node + linkType: hard + "raw-loader@npm:^4.0.2": version: 4.0.2 resolution: "raw-loader@npm:4.0.2" @@ -20244,6 +20598,19 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: ^4.4.0 + depd: ^2.0.0 + is-promise: ^4.0.0 + parseurl: ^1.3.3 + path-to-regexp: ^8.0.0 + checksum: 4c3bec8011ed10bb07d1ee860bc715f245fff0fdff991d8319741d2932d89c3fe0a56766b4fa78e95444bc323fd2538e09c8e43bfbd442c2a7fab67456df7fa5 + languageName: node + linkType: hard + "rtl-css-js@npm:^1.16.1": version: 1.16.1 resolution: "rtl-css-js@npm:1.16.1" @@ -20529,6 +20896,25 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.1 + resolution: "send@npm:1.2.1" + dependencies: + debug: ^4.4.3 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + etag: ^1.8.1 + fresh: ^2.0.0 + http-errors: ^2.0.1 + mime-types: ^3.0.2 + ms: ^2.1.3 + on-finished: ^2.4.1 + range-parser: ^1.2.1 + statuses: ^2.0.2 + checksum: 5361e3556fbc874c080a4cfbb4541e02c16221ca3c68c4f692320d38ef7e147381f805ce3ac50dfaa2129f07daa81098e2bc567e9a4d13993a92893d59a64d68 + languageName: node + linkType: hard + "serialize-javascript@npm:^4.0.0": version: 4.0.0 resolution: "serialize-javascript@npm:4.0.0" @@ -20574,6 +20960,18 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"serve-static@npm:^2.2.0": + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" + dependencies: + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + parseurl: ^1.3.3 + send: ^1.2.0 + checksum: dd71e9a316a7d7f726503973c531168cfa6a6a56a98d5c6b279c4d0d41a83a1bc6900495dc0633712b95d88ccbf9ed4f4a780a4c4c00bf84b496e9e710d68825 + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -20632,7 +21030,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"setprototypeof@npm:1.2.0": +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89 @@ -21129,6 +21527,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 + languageName: node + linkType: hard + "stealthy-require@npm:^1.1.1": version: 1.1.1 resolution: "stealthy-require@npm:1.1.1" @@ -21811,7 +22216,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"toidentifier@npm:1.0.1": +"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 @@ -22192,6 +22597,17 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"type-is@npm:^2.0.1": + version: 2.0.1 + resolution: "type-is@npm:2.0.1" + dependencies: + content-type: ^1.0.5 + media-typer: ^1.1.0 + mime-types: ^3.0.0 + checksum: 0266e7c782238128292e8c45e60037174d48c6366bb2d45e6bd6422b611c193f83409a8341518b6b5f33f8e4d5a959f38658cacfea77f0a3505b9f7ac1ddec8f + languageName: node + linkType: hard + "type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" @@ -22398,6 +22814,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 23da306c8366574adec305b06a8519ab5c7d09e3f5d16c1a98709a34fae17da09ec95198f30f86c00055e02efa8bfcc843e84e8aebeb9b8d6bb3e06afccae07a + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -22881,7 +23304,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"vary@npm:~1.1.2": +"vary@npm:^1, vary@npm:^1.1.2, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b @@ -23687,6 +24110,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"wonka@npm:^6.3.5": + version: 6.3.5 + resolution: "wonka@npm:6.3.5" + checksum: bd9f4330664ea971ddbc762275c081d5a635bcebd1c567211d43278b925f3394ad454bb33a0ef5e8beadfaad552cdbc92c018dfb96350f3895341998efa5f521 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5, word-wrap@npm:~1.2.3": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" @@ -23872,6 +24302,17 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"y-indexeddb@npm:^9.0.12": + version: 9.0.12 + resolution: "y-indexeddb@npm:9.0.12" + dependencies: + lib0: ^0.2.74 + peerDependencies: + yjs: ^13.0.0 + checksum: 0bc53723f91d322873ba44dade45dac127cc1a1be563437c7079d4c29a467c6854346d397761cf67c53e118b285e969fa284b9287f3c2bddbfff05c101b2f153 + languageName: node + linkType: hard + "y-leveldb@npm:^0.1.0": version: 0.1.2 resolution: "y-leveldb@npm:0.1.2" @@ -24039,6 +24480,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"zod@npm:^4.3.5, zod@npm:^4.3.6": + version: 4.3.6 + resolution: "zod@npm:4.3.6" + checksum: 19cec761b46bae4b6e7e861ea740f3f248e50a6671825afc8a5758e27b35d6f20ccde9942422fd5cf6f8b697f18bd05ef8bb33f5f2db112ab25cc628de2fae47 + languageName: node + linkType: hard + "zrender@npm:5.6.1, zrender@npm:^5.1.1": version: 5.6.1 resolution: "zrender@npm:5.6.1" From 33b9efe41f387390afc810c97e76111106110091 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 12 Mar 2026 00:08:25 +0500 Subject: [PATCH 27/34] fix pluv integration issues --- client/packages/lowcoder/package.json | 3 +- client/packages/lowcoder/pluv-server.js | 54 +- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 5 +- .../comps/chatBoxComponentv2/store/index.ts | 53 +- .../chatBoxComponentv2/store/pluvClient.ts | 76 +++ .../comps/chatBoxComponentv2/useChatStore.ts | 576 ++++++++++-------- client/yarn.lock | 11 +- 7 files changed, 459 insertions(+), 319 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index eab8c6a5f..7c9fdaa60 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -136,11 +136,12 @@ "y-protocols": "^1.0.6", "y-websocket": "^3.0.0", "yjs": "^13.6.27", - "zod": "^4.3.6" + "zod": "^3.25.76" }, "scripts": { "supportedBrowsers": "yarn dlx browserslist-useragent-regexp --allowHigherVersions '>0.2%,not dead,not op_mini all,chrome >=69'", "start": "REACT_APP_LOG_LEVEL=debug REACT_APP_ENV=local vite", + "start:pluv": "node pluv-server.js", "build": "vite build && cp ../../VERSION ./build/VERSION", "preview": "vite preview", "prepare": "husky install" diff --git a/client/packages/lowcoder/pluv-server.js b/client/packages/lowcoder/pluv-server.js index 7cf247aea..ff70d0f0f 100644 --- a/client/packages/lowcoder/pluv-server.js +++ b/client/packages/lowcoder/pluv-server.js @@ -90,7 +90,32 @@ app.get("/health", (_req, res) => { }); }); -// Auth endpoint — creates a JWT token for the requesting user +// Webhook endpoint — pluv.io sends server events here +app.post("/api/pluv/webhook", async (req, res) => { + try { + // Convert express req/res to a standard Request for ioServer.fetch + const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`; + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") headers.set(key, value); + } + + const fetchReq = new Request(url, { + method: req.method, + headers, + body: req.method !== "GET" ? JSON.stringify(req.body) : undefined, + }); + + const fetchRes = await ioServer.fetch(fetchReq); + const body = await fetchRes.text(); + res.status(fetchRes.status).send(body); + } catch (err) { + console.error("[pluv] Webhook error:", err); + res.status(500).json({ error: "Webhook handling failed" }); + } +}); + +// Auth endpoint — creates a JWT token for the requesting user (must be after webhook route) app.get("/api/auth/pluv", async (req, res) => { try { const room = req.query.room; @@ -117,36 +142,11 @@ app.get("/api/auth/pluv", async (req, res) => { } }); -// Webhook endpoint — pluv.io sends server events here -app.all("/api/pluv", async (req, res) => { - try { - // Convert express req/res to a standard Request for ioServer.fetch - const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`; - const headers = new Headers(); - for (const [key, value] of Object.entries(req.headers)) { - if (typeof value === "string") headers.set(key, value); - } - - const fetchReq = new Request(url, { - method: req.method, - headers, - body: req.method !== "GET" ? JSON.stringify(req.body) : undefined, - }); - - const fetchRes = await ioServer.fetch(fetchReq); - const body = await fetchRes.text(); - res.status(fetchRes.status).send(body); - } catch (err) { - console.error("[pluv] Webhook error:", err); - res.status(500).json({ error: "Webhook handling failed" }); - } -}); - // ── Start server ────────────────────────────────────────────────────────── app.listen(PORT, HOST, () => { console.log(`\n Pluv Chat Server running on http://${HOST}:${PORT}`); console.log(` Auth endpoint: GET /api/auth/pluv?room=...&userId=...`); - console.log(` Webhook endpoint: POST /api/pluv`); + console.log(` Webhook endpoint: POST /api/pluv/webhook`); console.log(` Health check: GET /health\n`); }); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index c2c5b591d..19e5c8679 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -147,10 +147,13 @@ let ChatBoxV2Tmp = (function () { const userName = props.userName.value || "User"; const roomName = `chatv2_${appId}`; - // Update the module-level config before pluv connects + // Update the module-level config before pluv connects. + // publicKey MUST be set here so resolvePluvPublicKey() returns the right + // value when PluvRoomProvider opens its WebSocket connection. pluvConfig.userId = userId; pluvConfig.userName = userName; pluvConfig.authUrl = props.pluvAuthUrl || "/api/auth/pluv"; + pluvConfig.publicKey = props.pluvPublicKey || ""; return ( (); - -export function getChatStore( - applicationId: string, - wsUrl = "ws://localhost:3005", -): ChatStore { - const entry = storeCache.get(applicationId); - if (entry) { - entry.refCount++; - return entry.store; - } - - const store = new ChatStore(applicationId, wsUrl); - storeCache.set(applicationId, { store, refCount: 1 }); - return store; -} - -export function releaseChatStore(applicationId: string): void { - const entry = storeCache.get(applicationId); - if (!entry) return; +export { uid, LLM_BOT_AUTHOR_ID } from "./types"; - entry.refCount--; - if (entry.refCount <= 0) { - entry.store.destroy(); - storeCache.delete(applicationId); - } -} +export { + PluvRoomProvider, + useStorage, + useTransact, + useMyPresence, + useMyself, + useOthers, + useRoom, + useConnection, + useDoc, + pluvConfig, +} from "./pluvClient"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts new file mode 100644 index 000000000..558cb5d43 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts @@ -0,0 +1,76 @@ +import { createClient } from "@pluv/client"; +import { yjs } from "@pluv/crdt-yjs"; +import { createBundle } from "@pluv/react"; +import { z } from "zod"; + +/** + * Module-level config object updated by the component before connecting. + * This allows dynamic auth without recreating the client. + * + * IMPORTANT: set pluvConfig.publicKey (from the component's pluvPublicKey prop) + * BEFORE PluvRoomProvider mounts so the pluv client has the key at connection time. + */ +export const pluvConfig = { + userId: "", + userName: "", + authUrl: "/api/auth/pluv", + /** Populated from the component's "Public Key" property-panel field. */ + publicKey: "", +}; + +/** + * Returns the public key at call-time (lazy) so the component can set + * pluvConfig.publicKey before pluv opens its first WebSocket connection. + * Falls back to build-time env vars / globalThis for non-component usages. + */ +function resolvePluvPublicKey(): string { + return ( + pluvConfig.publicKey || + (typeof globalThis !== "undefined" + ? (globalThis as any).__PLUV_PUBLIC_KEY__ + : "") || + (typeof import.meta !== "undefined" + ? (import.meta as any).env?.VITE_PLUV_PUBLIC_KEY + : "") || + "" + ); +} + +const client = createClient({ + authEndpoint: (({ room }: { room: string }) => { + const params = new URLSearchParams({ + room, + userId: pluvConfig.userId, + userName: pluvConfig.userName, + }); + return `${pluvConfig.authUrl}?${params}`; + }) as any, + publicKey: resolvePluvPublicKey as any, + initialStorage: yjs.doc((t: any) => ({ + rooms: t.map("rooms", []), + members: t.map("members", []), + invites: t.map("invites", []), + messages: t.map("messages", []), + })), + presence: z.object({ + typing: z + .object({ + userId: z.string(), + userName: z.string(), + roomId: z.string(), + }) + .nullable(), + }), +} as any); + +export const { + PluvRoomProvider, + useStorage, + useTransact, + useMyPresence, + useMyself, + useOthers, + useRoom, + useConnection, + useDoc, +} = createBundle(client); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts index 1e07d5fe7..94dd953c4 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -1,29 +1,36 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { routeByNameAction, executeQueryAction } from "lowcoder-core"; import { getPromiseAfterDispatch } from "util/promiseUtils"; +import { + useStorage, + useMyPresence, + useOthers, + useConnection, + LLM_BOT_AUTHOR_ID, + uid, +} from "./store"; import type { ChatMessage, ChatRoom, RoomMember, + RoomInvite, TypingUser, - ChangeType, } from "./store"; -import { getChatStore, releaseChatStore, LLM_BOT_AUTHOR_ID } from "./store"; -import type { ChatStore } from "./store"; + +// ── Public interfaces ────────────────────────────────────────────────────── export interface UseChatStoreConfig { - applicationId: string; userId: string; userName: string; - wsUrl: string; - /** Lowcoder component dispatch — required for firing LLM queries. */ dispatch?: (...args: any[]) => void; - /** System prompt prepended to conversation history passed to the query. */ systemPrompt?: string; - /** Display name for AI-generated messages. */ llmBotName?: string; } +export interface PendingRoomInvite extends RoomInvite { + roomName: string; +} + export interface UseChatStoreReturn { ready: boolean; error: string | null; @@ -35,59 +42,45 @@ export interface UseChatStoreReturn { userRooms: ChatRoom[]; currentRoomMembers: RoomMember[]; typingUsers: TypingUser[]; + pendingInvites: PendingRoomInvite[]; sendMessage: (text: string) => Promise; - switchRoom: (roomId: string) => Promise; + switchRoom: (roomId: string) => void; createRoom: ( name: string, type: "public" | "private" | "llm", description?: string, llmQueryName?: string, ) => Promise; - joinRoom: (roomId: string) => Promise; - leaveRoom: (roomId: string) => Promise; + joinRoom: (roomId: string) => boolean; + leaveRoom: (roomId: string) => boolean; searchRooms: (query: string) => Promise; + sendPrivateInvite: (toUserId: string, toUserName?: string) => Promise; + acceptInvite: (inviteId: string) => boolean; + declineInvite: (inviteId: string) => boolean; startTyping: () => void; stopTyping: () => void; } -// ── Response extraction ──────────────────────────────────────────────────── - -/** - * Pulls a text string out of whatever the Lowcoder query returned. - * - * Supported shapes (checked in priority order): - * OpenAI / Ollama-compatible : result.choices[0].message.content - * Ollama /api/chat : result.message.content - * Anthropic : result.content[0].text - * Simple object : result.content | result.text | result.response | result.output - * chatComp style : result.message (string) - * Plain string : result - */ +// ── LLM response extraction ─────────────────────────────────────────────── + function extractAiText(result: any): string { if (!result) return "No response received."; if (typeof result === "string") return result; - // OpenAI / Ollama OpenAI-compat / LM Studio → choices[0].message.content if (Array.isArray(result.choices) && result.choices.length > 0) { const choice = result.choices[0]; if (choice?.message?.content) return String(choice.message.content); if (choice?.text) return String(choice.text); } - - // Anthropic → content[0].text if (Array.isArray(result.content) && result.content.length > 0) { const first = result.content[0]; if (first?.text) return String(first.text); } - - // Ollama /api/chat native format → message.content if (result.message && typeof result.message === "object" && result.message.content) { return String(result.message.content); } if (result.message && typeof result.message === "string") return result.message; - - // Simple flat shapes (custom APIs, N8N, etc.) if (result.content && typeof result.content === "string") return result.content; if (result.text && typeof result.text === "string") return result.text; if (result.response && typeof result.response === "string") return result.response; @@ -95,32 +88,38 @@ function extractAiText(result: any): string { if (result.answer && typeof result.answer === "string") return result.answer; if (result.reply && typeof result.reply === "string") return result.reply; - // Fallback — pretty-print whatever came back - try { return JSON.stringify(result, null, 2); } catch { return String(result); } + try { + return JSON.stringify(result, null, 2); + } catch { + return String(result); + } } -// ── Hook ────────────────────────────────────────────────────────────────── +// ── Hook ─────────────────────────────────────────────────────────────────── export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { - const { applicationId, userId, userName, wsUrl, dispatch, systemPrompt, llmBotName } = config; + const { userId, userName, dispatch, systemPrompt, llmBotName } = config; - const storeRef = useRef(null); - const [ready, setReady] = useState(false); - const [error, setError] = useState(null); - const [connectionLabel, setConnectionLabel] = useState("Connecting..."); - const [isLlmLoading, setIsLlmLoading] = useState(false); + // ── Pluv storage hooks (reactive — re-render on change) ────────────── + const [rooms, roomsYMap] = useStorage("rooms"); + const [members, membersYMap] = useStorage("members"); + const [allMessages, messagesYMap] = useStorage("messages"); + const [invites, invitesYMap] = useStorage("invites"); - const [currentRoom, setCurrentRoom] = useState(null); - const [messages, setMessages] = useState([]); - const [userRooms, setUserRooms] = useState([]); - const [currentRoomMembers, setCurrentRoomMembers] = useState([]); - const [typingUsers, setTypingUsers] = useState([]); + // ── Pluv presence + connection ─────────────────────────────────────── + const [, updateMyPresence] = useMyPresence(); + const others = useOthers(); + const connection = useConnection(); + + // ── Local state ────────────────────────────────────────────────────── + const [activeRoomId, setActiveRoomId] = useState(null); + const [isLlmLoading, setIsLlmLoading] = useState(false); + const [error] = useState(null); - const activeRoomIdRef = useRef(null); - const currentRoomRef = useRef(null); + const activeRoomIdRef = useRef(activeRoomId); + activeRoomIdRef.current = activeRoomId; - // Keep refs in sync so callbacks always see latest values without - // needing to be in dependency arrays. + // Keep refs in sync so callbacks see latest values const dispatchRef = useRef(dispatch); const systemPromptRef = useRef(systemPrompt); const llmBotNameRef = useRef(llmBotName); @@ -128,120 +127,96 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { useEffect(() => { systemPromptRef.current = systemPrompt; }, [systemPrompt]); useEffect(() => { llmBotNameRef.current = llmBotName; }, [llmBotName]); - // ── Granular refresh helpers ──────────────────────────────────────────── - - const refreshRooms = useCallback(async () => { - const store = storeRef.current; - if (!store || !userId) return; - try { - const rooms = await store.getUserRooms(userId); - setUserRooms(rooms); - } catch { /* non-fatal */ } - }, [userId]); - - const refreshMessages = useCallback(async () => { - const store = storeRef.current; - const roomId = activeRoomIdRef.current; - if (!store || !roomId) return; - try { - const msgs = await store.getMessages(roomId); - setMessages(msgs); - } catch { /* non-fatal */ } - }, []); - - const refreshMembers = useCallback(async () => { - const store = storeRef.current; - const roomId = activeRoomIdRef.current; - if (!store || !roomId) return; - try { - const members = await store.getRoomMembers(roomId); - setCurrentRoomMembers(members); - } catch { /* non-fatal */ } - }, []); - - const refreshTyping = useCallback(() => { - const store = storeRef.current; - const roomId = activeRoomIdRef.current; - if (!store || !roomId) return; - const users = store.getTypingUsers(roomId, userId); - setTypingUsers(users); - }, [userId]); - - const refreshConnection = useCallback(() => { - const store = storeRef.current; - if (store) setConnectionLabel(store.getConnectionLabel()); - }, []); - - const handleStoreChange = useCallback( - (changes: Set) => { - if (changes.has("rooms") || changes.has("members")) refreshRooms(); - if (changes.has("messages")) refreshMessages(); - if (changes.has("members")) refreshMembers(); - if (changes.has("connection")) refreshConnection(); - if (changes.has("typing")) refreshTyping(); - }, - [refreshRooms, refreshMessages, refreshMembers, refreshConnection, refreshTyping], - ); - - // ── Initialization ────────────────────────────────────────────────────── - - useEffect(() => { - if (!applicationId || !userId || !userName) return; + // ── Derived state ──────────────────────────────────────────────────── + + const ready = connection.state === "open" && rooms != null; + + const connectionLabel = useMemo(() => { + if (connection.state === "open") return "Online"; + if (connection.state === "connecting") return "Connecting..."; + return "Offline"; + }, [connection.state]); + + const roomsRecord = rooms as Record | null; + const membersRecord = members as Record | null; + const messagesRecord = allMessages as Record | null; + const invitesRecord = invites as Record | null; + + const currentRoom = useMemo(() => { + if (!activeRoomId || !roomsRecord) return null; + return roomsRecord[activeRoomId] ?? null; + }, [roomsRecord, activeRoomId]); + + const userRooms = useMemo(() => { + if (!roomsRecord || !membersRecord) return []; + const memberRoomIds = new Set(); + for (const member of Object.values(membersRecord)) { + if (member.userId === userId) memberRoomIds.add(member.roomId); + } + return Object.values(roomsRecord) + .filter((r) => memberRoomIds.has(r.id)) + .sort((a, b) => b.updatedAt - a.updatedAt); + }, [roomsRecord, membersRecord, userId]); + + const messages = useMemo(() => { + if (!messagesRecord || !activeRoomId) return []; + return Object.values(messagesRecord) + .filter((m) => m.roomId === activeRoomId) + .sort((a, b) => a.timestamp - b.timestamp); + }, [messagesRecord, activeRoomId]); + + const currentRoomMembers = useMemo(() => { + if (!membersRecord || !activeRoomId) return []; + return Object.values(membersRecord) + .filter((m) => m.roomId === activeRoomId) + .sort((a, b) => a.joinedAt - b.joinedAt); + }, [membersRecord, activeRoomId]); + + const typingUsers = useMemo(() => { + if (!activeRoomId) return []; + return others + .filter((o) => { + const t = (o.presence as any)?.typing; + return t?.roomId === activeRoomId && t?.userId !== userId; + }) + .map((o) => (o.presence as any).typing as TypingUser); + }, [others, activeRoomId, userId]); + + const pendingInvites = useMemo(() => { + if (!invitesRecord || !roomsRecord) return []; + return Object.values(invitesRecord) + .filter((inv) => inv.toUserId === userId && inv.status === "pending") + .map((inv) => { + const room = roomsRecord[inv.roomId]; + if (!room || room.type !== "private") return null; + return { ...inv, roomName: room.name }; + }) + .filter((v): v is PendingRoomInvite => v != null) + .sort((a, b) => b.createdAt - a.createdAt); + }, [invitesRecord, roomsRecord, userId]); + + // ── LLM query invocation ───────────────────────────────────────────── - let cancelled = false; - const store = getChatStore(applicationId, wsUrl); - storeRef.current = store; - - (async () => { - try { - await store.init(); - if (cancelled) return; - - const rooms = await store.getUserRooms(userId); - if (cancelled) return; - - setUserRooms(rooms); - setConnectionLabel(store.getConnectionLabel()); - setReady(true); - } catch (e) { - if (!cancelled) - setError(e instanceof Error ? e.message : "Failed to initialize chat store"); - } - })(); - - const unsub = store.subscribe((changes) => { - if (!cancelled) handleStoreChange(changes); - }); - - return () => { - cancelled = true; - unsub(); - releaseChatStore(applicationId); - }; - }, [applicationId, userId, userName, wsUrl, handleStoreChange]); - - // ── LLM query invocation ───────────────────────────────────────────────── - - /** - * Fires the configured Lowcoder query for the current LLM room, passing: - * - prompt / message : the user's text (backward compat) - * - conversationHistory : [{role, content}] array for AI APIs - * - systemPrompt : the configured system prompt - * - roomId : so the query can segment by room if needed - */ const invokeLlmQuery = useCallback( async (queryName: string, userText: string, roomId: string): Promise => { - const store = storeRef.current; const currentDispatch = dispatchRef.current; - if (!currentDispatch) { - return "(LLM error: no dispatch available. Is the component configured?)"; + return "(LLM error: no dispatch available)"; } - // Build history before the message we just sent (inclusive of it) - const rawHistory = store ? await store.getLlmConversationHistory(roomId) : []; + const rawHistory: Array<{ role: "user" | "assistant"; content: string }> = []; + if (messagesRecord) { + const roomMsgs = Object.values(messagesRecord) + .filter((m) => m.roomId === roomId) + .sort((a, b) => a.timestamp - b.timestamp); + for (const m of roomMsgs) { + rawHistory.push({ + role: m.authorType === "assistant" ? "assistant" : "user", + content: m.text, + }); + } + } - // Prepend system prompt if configured const sysPrompt = systemPromptRef.current?.trim(); const conversationHistory = sysPrompt ? [{ role: "system" as const, content: sysPrompt }, ...rawHistory] @@ -269,43 +244,61 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { throw new Error(e?.message || "LLM query failed"); } }, - [], + [messagesRecord], ); - // ── Actions ────────────────────────────────────────────────────────────── + // ── Actions ────────────────────────────────────────────────────────── - /** - * Sends a user message. If the active room is an LLM room the sender's - * client also fires the configured query and writes the AI response to YJS, - * which syncs to all connected members automatically. - */ const sendMessage = useCallback( async (text: string): Promise => { - const store = storeRef.current; const roomId = activeRoomIdRef.current; - const room = currentRoomRef.current; - if (!store || !roomId || !text.trim()) return false; + if (!messagesYMap || !roomsYMap || !roomId || !text.trim()) return false; try { - // 1. Write user message (visible to everyone immediately via YJS) - await store.sendMessage(roomId, userId, userName, text.trim(), "user"); + const now = Date.now(); + const msg: ChatMessage = { + id: uid(), + roomId, + authorId: userId, + authorName: userName, + text: text.trim(), + timestamp: now, + authorType: "user", + }; + messagesYMap.set(msg.id, msg); + + const room = roomsYMap.get(roomId) as ChatRoom | undefined; + if (room) { + roomsYMap.set(roomId, { ...room, updatedAt: now }); + } - // 2. If LLM room — fire query and write AI response if (room?.type === "llm" && room.llmQueryName) { setIsLlmLoading(true); try { const aiText = await invokeLlmQuery(room.llmQueryName, text.trim(), roomId); const botName = llmBotNameRef.current || "AI Assistant"; - await store.sendMessage(roomId, LLM_BOT_AUTHOR_ID, botName, aiText, "assistant"); + const aiMsg: ChatMessage = { + id: uid(), + roomId, + authorId: LLM_BOT_AUTHOR_ID, + authorName: botName, + text: aiText, + timestamp: Date.now(), + authorType: "assistant", + }; + messagesYMap.set(aiMsg.id, aiMsg); } catch (e: any) { const botName = llmBotNameRef.current || "AI Assistant"; - await store.sendMessage( + const errMsg: ChatMessage = { + id: uid(), roomId, - LLM_BOT_AUTHOR_ID, - botName, - `Sorry, I encountered an error: ${e?.message || "unknown"}`, - "assistant", - ); + authorId: LLM_BOT_AUTHOR_ID, + authorName: botName, + text: `Sorry, I encountered an error: ${e?.message || "unknown"}`, + timestamp: Date.now(), + authorType: "assistant", + }; + messagesYMap.set(errMsg.id, errMsg); } finally { setIsLlmLoading(false); } @@ -316,25 +309,19 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { return false; } }, - [userId, userName, invokeLlmQuery], + [messagesYMap, roomsYMap, userId, userName, invokeLlmQuery], ); - const switchRoom = useCallback(async (roomId: string) => { - const store = storeRef.current; - if (!store) return; - const room = await store.getRoom(roomId); - if (!room) return; - activeRoomIdRef.current = room.id; - currentRoomRef.current = room; - setCurrentRoom(room); - const [msgs, members] = await Promise.all([ - store.getMessages(room.id), - store.getRoomMembers(room.id), - ]); - setMessages(msgs); - setCurrentRoomMembers(members); - setIsLlmLoading(false); - }, []); + const switchRoom = useCallback( + (roomId: string) => { + if (!roomsRecord) return; + const room = roomsRecord[roomId]; + if (!room) return; + setActiveRoomId(roomId); + setIsLlmLoading(false); + }, + [roomsRecord], + ); const createRoom = useCallback( async ( @@ -343,85 +330,172 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { description?: string, llmQueryName?: string, ): Promise => { - const store = storeRef.current; - if (!store) return null; - try { - return await store.createRoom(name, type, userId, userName, description, llmQueryName); - } catch { - return null; - } + if (!roomsYMap || !membersYMap) return null; + const roomId = uid(); + const now = Date.now(); + const room: ChatRoom = { + id: roomId, + name, + description: description || "", + type, + llmQueryName: type === "llm" ? (llmQueryName ?? "") : undefined, + creatorId: userId, + createdAt: now, + updatedAt: now, + }; + roomsYMap.set(roomId, room); + membersYMap.set(`${roomId}::${userId}`, { + roomId, + userId, + userName, + joinedAt: now, + } as RoomMember); + return room; }, - [userId, userName], + [roomsYMap, membersYMap, userId, userName], ); const joinRoom = useCallback( - async (roomId: string): Promise => { - const store = storeRef.current; - if (!store) return false; - try { - const ok = await store.joinRoom(roomId, userId, userName); - if (ok) await switchRoom(roomId); - return ok; - } catch { - return false; - } + (roomId: string): boolean => { + if (!membersYMap) return false; + const key = `${roomId}::${userId}`; + if ((membersRecord ?? {})[key]) return true; + membersYMap.set(key, { + roomId, + userId, + userName, + joinedAt: Date.now(), + } as RoomMember); + setActiveRoomId(roomId); + return true; }, - [userId, userName, switchRoom], + [membersYMap, membersRecord, userId, userName], ); const leaveRoom = useCallback( - async (roomId: string): Promise => { - const store = storeRef.current; - if (!store) return false; - try { - const ok = await store.leaveRoom(roomId, userId); - if (ok && activeRoomIdRef.current === roomId) { - const rooms = await store.getUserRooms(userId); - if (rooms.length > 0) { - await switchRoom(rooms[0].id); - } else { - activeRoomIdRef.current = null; - currentRoomRef.current = null; - setCurrentRoom(null); - setMessages([]); - setCurrentRoomMembers([]); - setIsLlmLoading(false); - } + (roomId: string): boolean => { + if (!membersYMap) return false; + membersYMap.delete(`${roomId}::${userId}`); + if (activeRoomIdRef.current === roomId) { + const remaining = userRooms.filter((r) => r.id !== roomId); + if (remaining.length > 0) { + setActiveRoomId(remaining[0].id); + } else { + setActiveRoomId(null); + setIsLlmLoading(false); } - return ok; - } catch { - return false; } + return true; }, - [userId, switchRoom], + [membersYMap, userId, userRooms], ); const searchRooms = useCallback( async (query: string): Promise => { - const store = storeRef.current; - if (!store || !query.trim()) return []; - try { - return await store.getSearchableRooms(userId, query.trim()); - } catch { - return []; + if (!roomsRecord || !membersRecord || !query.trim()) return []; + const memberRoomIds = new Set(); + for (const member of Object.values(membersRecord)) { + if (member.userId === userId) memberRoomIds.add(member.roomId); } + const lq = query.toLowerCase(); + return Object.values(roomsRecord) + .filter((r) => { + if (r.type === "private") return false; + if (memberRoomIds.has(r.id)) return false; + return ( + r.name.toLowerCase().includes(lq) || + r.description.toLowerCase().includes(lq) + ); + }) + .sort((a, b) => b.updatedAt - a.updatedAt); }, - [userId], + [roomsRecord, membersRecord, userId], + ); + + const sendPrivateInvite = useCallback( + async (toUserId: string, toUserName?: string): Promise => { + if (!invitesYMap || !membersRecord) return false; + const room = activeRoomIdRef.current + ? roomsRecord?.[activeRoomIdRef.current] + : null; + const targetId = toUserId.trim(); + if (!room || room.type !== "private" || !targetId) return false; + if (targetId === userId) return false; + + if (membersRecord[`${room.id}::${targetId}`]) return false; + + if (invitesRecord) { + for (const inv of Object.values(invitesRecord)) { + if (inv.roomId === room.id && inv.toUserId === targetId && inv.status === "pending") { + return true; + } + } + } + + const invite: RoomInvite = { + id: uid(), + roomId: room.id, + fromUserId: userId, + fromUserName: userName, + toUserId: targetId, + toUserName: toUserName?.trim() || undefined, + status: "pending", + createdAt: Date.now(), + }; + invitesYMap.set(invite.id, invite); + return true; + }, + [invitesYMap, invitesRecord, membersRecord, roomsRecord, userId, userName], + ); + + const acceptInvite = useCallback( + (inviteId: string): boolean => { + if (!invitesYMap || !membersYMap || !invitesRecord) return false; + const current = invitesRecord[inviteId]; + if (!current || current.toUserId !== userId || current.status !== "pending") return false; + if (!roomsRecord?.[current.roomId]) return false; + + membersYMap.set(`${current.roomId}::${userId}`, { + roomId: current.roomId, + userId, + userName, + joinedAt: Date.now(), + } as RoomMember); + invitesYMap.set(inviteId, { + ...current, + status: "accepted", + respondedAt: Date.now(), + } as RoomInvite); + setActiveRoomId(current.roomId); + return true; + }, + [invitesYMap, membersYMap, invitesRecord, roomsRecord, userId, userName], + ); + + const declineInvite = useCallback( + (inviteId: string): boolean => { + if (!invitesYMap || !invitesRecord) return false; + const current = invitesRecord[inviteId]; + if (!current || current.toUserId !== userId || current.status !== "pending") return false; + invitesYMap.set(inviteId, { + ...current, + status: "declined", + respondedAt: Date.now(), + } as RoomInvite); + return true; + }, + [invitesYMap, invitesRecord, userId], ); const startTyping = useCallback(() => { - const store = storeRef.current; const roomId = activeRoomIdRef.current; - if (!store || !roomId) return; - store.startTyping(roomId, userId, userName); - }, [userId, userName]); + if (!roomId) return; + updateMyPresence({ typing: { userId, userName, roomId } }); + }, [updateMyPresence, userId, userName]); const stopTyping = useCallback(() => { - const store = storeRef.current; - const roomId = activeRoomIdRef.current; - if (!store || !roomId) return; - store.stopTyping(roomId, userId); - }, [userId]); + updateMyPresence({ typing: null }); + }, [updateMyPresence]); return { ready, @@ -433,12 +507,16 @@ export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { userRooms, currentRoomMembers, typingUsers, + pendingInvites, sendMessage, switchRoom, createRoom, joinRoom, leaveRoom, searchRooms, + sendPrivateInvite, + acceptInvite, + declineInvite, startTyping, stopTyping, }; diff --git a/client/yarn.lock b/client/yarn.lock index 35dada284..d0577d4bb 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -15575,7 +15575,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: y-protocols: ^1.0.6 y-websocket: ^3.0.0 yjs: ^13.6.27 - zod: ^4.3.6 + zod: ^3.25.76 languageName: unknown linkType: soft @@ -24480,7 +24480,14 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"zod@npm:^4.3.5, zod@npm:^4.3.6": +"zod@npm:^3.25.76": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: c9a403a62b329188a5f6bd24d5d935d2bba345f7ab8151d1baa1505b5da9f227fb139354b043711490c798e91f3df75991395e40142e6510a4b16409f302b849 + languageName: node + linkType: hard + +"zod@npm:^4.3.5": version: 4.3.6 resolution: "zod@npm:4.3.6" checksum: 19cec761b46bae4b6e7e861ea740f3f248e50a6671825afc8a5758e27b35d6f20ccde9942422fd5cf6f8b697f18bd05ef8bb33f5f2db112ab25cc628de2fae47 From 4da29c47ad49931dd7c27fec87a36f11dca620f2 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 12 Mar 2026 21:55:53 +0500 Subject: [PATCH 28/34] add chatController architecture --- .../comps/comps/chatBoxComponentv2/README.md | 357 +++++++++++ .../components/InputBar.tsx | 41 +- .../components/MessageList.tsx | 209 +++--- .../chatBoxComponentv2/store/pluvClient.ts | 29 +- .../comps/chatBoxComponentv2/store/types.ts | 43 +- .../src/comps/hooks/chatControllerV2Comp.tsx | 600 +++++++++++------- client/packages/lowcoder/src/comps/index.tsx | 12 +- 7 files changed, 889 insertions(+), 402 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md new file mode 100644 index 000000000..c5dcd0459 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md @@ -0,0 +1,357 @@ +# Chat V2 — Testing & Setup Guide + +## Architecture + +The Chat V2 system separates **real-time signaling** from **data storage**: + +| Layer | Component | Responsibility | +|-------|-----------|---------------| +| Signal | **Chat Signal Controller** | Pluv/Yjs — presence, typing, message notifications | +| UI | **Chat Box V2** | Pure display — renders messages, fires events | +| Storage | **Your Data Queries** | MongoDB, PostgreSQL, REST API, etc. | + +Pluv/Yjs only broadcasts ephemeral real-time data (who is online, who is typing, "a new message was just saved"). It does **not** store messages — that is your database's job. + +--- + +## Prerequisites + +### 1. Pluv.io Account + +Sign up at [pluv.io](https://pluv.io) and create a project. You will need: + +- **Publishable Key** (`pk_...`) — used by the client +- **Secret Key** (`sk_...`) — used by the auth server only + +### 2. Start the Pluv Auth Server + +```bash +cd client/packages/lowcoder + +# Set environment variables +export PLUV_PUBLISHABLE_KEY="pk_..." +export PLUV_SECRET_KEY="sk_..." + +# Start the server (defaults to port 3006) +npm run start:pluv +# or directly: +node pluv-server.js +``` + +Verify it's running: + +```bash +curl http://localhost:3006/health +# → { "status": "healthy", "server": "pluv-chat", ... } +``` + +--- + +## Quick Start — Minimal Chat in 5 Steps + +### Step 1: Add the Chat Signal Controller + +1. In the Lowcoder editor, open the **Insert** panel +2. Search for **"Chat Signal Controller"** (under Collaboration) +3. Drag it onto the canvas (it's headless — no visual output) +4. Configure in the property panel: + +| Property | Value | +|----------|-------| +| Application ID | `my_chat_app` (or any string — scopes the signal room) | +| User ID | `{{ currentUser.id }}` or a hardcoded test value like `user_1` | +| User Name | `{{ currentUser.name }}` or `Alice` | +| Public Key | Your Pluv publishable key (`pk_...`) | +| Auth URL | `http://localhost:3006/api/auth/pluv` | + +The controller is named `chatControllerV2` by default — you can rename it. + +### Step 2: Add the Chat Box V2 + +1. Search for **"Chat Box V2"** and drag it onto the canvas +2. Configure in the property panel: + +| Property | Value | Purpose | +|----------|-------|---------| +| Chat Title | `Team Chat` | Header display name | +| Messages | `{{ loadMessages.data }}` | Bind to your data query (Step 3) | +| Current User ID | `{{ chatControllerV2.userId }}` | Distinguishes own vs. others' messages | +| Current User Name | `{{ chatControllerV2.userName }}` | Display name | +| Typing Users | `{{ chatControllerV2.typingUsers }}` | Shows typing indicators | + +### Step 3: Create Data Queries + +You need two queries — one to **load** messages and one to **save** them. Use whatever data source you prefer. + +#### Example: MongoDB "loadMessages" query + +```js +// Query name: loadMessages +// Data source: MongoDB +// Collection: chat_messages +// Operation: Find +// Filter: +{ "roomId": "general" } +// Sort: +{ "timestamp": 1 } +``` + +#### Example: MongoDB "saveMessage" query + +```js +// Query name: saveMessage +// Data source: MongoDB +// Collection: chat_messages +// Operation: Insert +// Document: +{ + "id": {{ uuid() }}, + "roomId": "general", + "text": {{ chatBoxV2.lastSentMessageText }}, + "authorId": {{ chatControllerV2.userId }}, + "authorName": {{ chatControllerV2.userName }}, + "timestamp": {{ Date.now() }} +} +``` + +#### Example: REST API queries + +``` +// loadMessages +GET https://your-api.com/messages?roomId=general + +// saveMessage +POST https://your-api.com/messages +Body: { + "roomId": "general", + "text": {{ chatBoxV2.lastSentMessageText }}, + "authorId": {{ chatControllerV2.userId }}, + "authorName": {{ chatControllerV2.userName }}, + "timestamp": {{ Date.now() }} +} +``` + +### Step 4: Wire Up Events + +#### On the Chat Box V2: + +| Event | Action | +|-------|--------| +| **Message Sent** | 1. Run `saveMessage` query
2. Run `chatControllerV2.broadcastNewMessage("general")`
3. Run `loadMessages` query | +| **Start Typing** | Run `chatControllerV2.startTyping("general")` | +| **Stop Typing** | Run `chatControllerV2.stopTyping()` | + +#### On the Chat Signal Controller: + +| Event | Action | +|-------|--------| +| **New Message Broadcast** | Run `loadMessages` query (a peer saved a new message) | +| **Connected** | Run `loadMessages` query (initial load) | + +### Step 5: Test + +1. Open the app in **two browser tabs** (or two different browsers) +2. Set different User IDs for each tab (e.g. `user_1` / `user_2`) +3. Type in one tab — the other should show a typing indicator +4. Send a message — the other tab should see it appear after the broadcast triggers a reload + +--- + +## Message Data Format + +The Chat Box V2 accepts messages as a JSON array. It reads fields flexibly: + +| Priority 1 | Priority 2 | Priority 3 | Priority 4 | Purpose | +|------------|------------|------------|------------|---------| +| `id` | `_id` | — | — | Unique key for rendering | +| `text` | `message` | `content` | — | Message body | +| `authorId` | `userId` | `author_id` | `sender` | Author identification | +| `authorName` | `userName` | `author_name` | `senderName` | Display name | +| `timestamp` | `createdAt` | `created_at` | `time` | Time display | + +The `authorType` field (or `role`) with value `"assistant"` renders AI-style bubbles with markdown support and a copy button. + +So if your database uses `sender` instead of `authorId`, it will still work. + +--- + +## Rooms / Channels + +Rooms are **not managed by the components** — they live in your database. The controller and chatbox are room-agnostic; you decide how to filter and organize messages. + +### Single Room (Simplest) + +Hardcode a room ID in your queries: + +```js +// loadMessages filter +{ "roomId": "general" } +``` + +### Multiple Rooms + +Build a room selector using standard Lowcoder components (Select, List, etc.): + +1. Create a query to load rooms from your DB +2. Add a **Select** component bound to `{{ loadRooms.data }}` +3. Filter messages by selected room: + +```js +// loadMessages filter +{ "roomId": {{ roomSelect.value }} } +``` + +4. When switching rooms, call: + +```js +chatControllerV2.switchRoom(roomSelect.value) +``` + +This scopes the typing indicator to the selected room, so users in different rooms don't see each other's typing state. + +5. When sending, broadcast with the room ID: + +```js +chatControllerV2.broadcastNewMessage(roomSelect.value) +``` + +### Public vs. Private Rooms + +Since rooms are in your database, you control access: + +```js +// Public rooms query +{ "type": "public" } + +// Private rooms — only show rooms where the user is a member +{ "type": "private", "members": { "$in": [{{ currentUser.id }}] } } +``` + +There is no built-in room creation UI. Use a **Modal** or **Form** component with your own "createRoom" query. + +--- + +## Typing Indicators + +Typing indicators work automatically when you wire the events: + +1. **Chat Box V2** fires `startTyping` when the user begins typing and `stopTyping` after 2 seconds of inactivity +2. Wire these events to the controller methods: + - `startTyping` → `chatControllerV2.startTyping("roomId")` + - `stopTyping` → `chatControllerV2.stopTyping()` +3. Bind the Chat Box V2's **Typing Users** property to `{{ chatControllerV2.typingUsers }}` + +The typing indicator shows the names of users currently typing, scoped to the controller's `currentRoomId`. If you use `switchRoom()` when changing rooms, typing indicators are automatically scoped. + +--- + +## Online Users + +The controller exposes `{{ chatControllerV2.onlineUsers }}` — an array of: + +```json +[ + { "userId": "user_1", "userName": "Alice", "currentRoomId": "general" }, + { "userId": "user_2", "userName": "Bob", "currentRoomId": "design" } +] +``` + +Display this with any Lowcoder component (List, Table, Avatars, etc.): + +``` +{{ chatControllerV2.onlineUsers.length }} users online +``` + +--- + +## Controller Exposed Properties Reference + +Access these via `{{ chatControllerV2.propertyName }}`: + +| Property | Type | Description | +|----------|------|-------------| +| `ready` | `boolean` | Whether the signal server is connected | +| `connectionStatus` | `string` | `"Online"`, `"Connecting..."`, or `"Offline"` | +| `error` | `string \| null` | Error message if connection failed | +| `onlineUsers` | `Array<{ userId, userName, currentRoomId }>` | Currently connected users | +| `typingUsers` | `Array<{ userId, userName, roomId }>` | Users currently typing | +| `currentRoomId` | `string \| null` | Active room set via `switchRoom()` | +| `lastMessageNotification` | `Object \| null` | Last broadcast: `{ roomId, messageId, authorId, authorName, timestamp }` | +| `userId` | `string` | Current user ID | +| `userName` | `string` | Current user name | +| `applicationId` | `string` | Application scope ID | + +## Controller Methods Reference + +Call these via `chatControllerV2.methodName(args)` in event handlers: + +| Method | Params | Description | +|--------|--------|-------------| +| `broadcastNewMessage(roomId, messageId?)` | `roomId`: string, `messageId`: string (optional) | Notify all peers a message was saved — triggers their `onNewMessageBroadcast` event | +| `startTyping(roomId?)` | `roomId`: string (optional) | Set typing indicator for current user | +| `stopTyping()` | — | Clear typing indicator | +| `switchRoom(roomId)` | `roomId`: string | Set current room context for presence scoping | +| `setUser(userId, userName)` | `userId`: string, `userName`: string | Update identity at runtime | + +## Chat Box V2 Exposed Properties Reference + +Access these via `{{ chatBoxV2.propertyName }}`: + +| Property | Type | Description | +|----------|------|-------------| +| `lastSentMessageText` | `string` | Text of the last message the user sent — use in your save query | +| `messageText` | `string` | Current text in the input (live draft) | +| `chatTitle` | `string` | The configured chat title | + +## Chat Box V2 Events Reference + +| Event | When | Typical action | +|-------|------|----------------| +| `messageSent` | User presses Enter or Send | Run save query, broadcast, reload messages | +| `startTyping` | User begins typing | `chatControllerV2.startTyping(roomId)` | +| `stopTyping` | User idle for 2s | `chatControllerV2.stopTyping()` | + +--- + +## Testing Checklist + +### Basic messaging +- [ ] Start pluv-server (`node pluv-server.js`) +- [ ] Add Chat Signal Controller with valid Pluv keys and Auth URL +- [ ] Add Chat Box V2 with messages bound to a data query +- [ ] Verify `chatControllerV2.ready` shows `true` +- [ ] Verify `chatControllerV2.connectionStatus` shows `"Online"` +- [ ] Send a message — `lastSentMessageText` updates +- [ ] Message appears in your database +- [ ] Message appears in the chat after reload + +### Real-time sync (two browser tabs) +- [ ] Tab A sends a message → Tab B's `onNewMessageBroadcast` fires → messages reload +- [ ] Tab A types → Tab B sees typing indicator +- [ ] Tab A stops typing (2s idle) → indicator disappears +- [ ] Tab B sees Tab A in `onlineUsers` +- [ ] Tab A closes → Tab B's `userLeft` event fires + +### Multi-room +- [ ] Switch rooms via `chatControllerV2.switchRoom(roomId)` +- [ ] Messages filter to the selected room +- [ ] Typing indicators scope to the current room +- [ ] Broadcasting targets the correct room + +### Error handling +- [ ] Invalid Pluv key → `error` event fires, `error` property set +- [ ] Pluv server down → `connectionStatus` shows `"Offline"`, `disconnected` event fires +- [ ] Server comes back → `connected` event fires, status returns to `"Online"` + +--- + +## Troubleshooting + +| Symptom | Check | +|---------|-------| +| `connectionStatus` stuck on `"Connecting..."` | Verify pluv-server is running and Auth URL is correct | +| Auth fails | Check browser console for `[ChatControllerV2] Auth failed` — verify Pluv keys match | +| Messages don't appear | Check your `loadMessages` query returns the correct format | +| Typing not showing | Verify `typingUsers` is bound to `{{ chatControllerV2.typingUsers }}` and events are wired | +| Broadcasts not received | Ensure both users have the same `applicationId` | +| Own messages show as "other" | Check `currentUserId` matches the `authorId` in your message data | diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx index bb72fe9b1..dcfa28b2d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx @@ -1,23 +1,17 @@ import React, { useCallback, useRef, useState } from "react"; import { Button } from "antd"; import { SendOutlined } from "@ant-design/icons"; -import type { ChatRoom } from "../store"; import { InputBarContainer, StyledTextArea } from "../styles"; export interface InputBarProps { - ready: boolean; - currentRoom: ChatRoom | null; - onSend: (text: string) => Promise; + onSend: (text: string) => void; onStartTyping: () => void; onStopTyping: () => void; - onMessageSentEvent: () => void; - isLlmLoading?: boolean; - isLlmRoom?: boolean; + onDraftChange: (text: string) => void; } export const InputBar = React.memo((props: InputBarProps) => { - const { ready, currentRoom, onSend, onStartTyping, onStopTyping, onMessageSentEvent, isLlmLoading, isLlmRoom } = props; - const isDisabled = !ready || !currentRoom || !!isLlmLoading; + const { onSend, onStartTyping, onStopTyping, onDraftChange } = props; const [draft, setDraft] = useState(""); const typingTimeoutRef = useRef | null>(null); const isTypingRef = useRef(false); @@ -37,15 +31,13 @@ export const InputBar = React.memo((props: InputBarProps) => { } }, [onStopTyping, clearTypingTimeout]); - const handleSend = useCallback(async () => { + const handleSend = useCallback(() => { if (!draft.trim()) return; handleStopTyping(); - const ok = await onSend(draft); - if (ok) { - setDraft(""); - onMessageSentEvent(); - } - }, [draft, onSend, onMessageSentEvent, handleStopTyping]); + onSend(draft.trim()); + setDraft(""); + onDraftChange(""); + }, [draft, onSend, handleStopTyping, onDraftChange]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -61,6 +53,7 @@ export const InputBar = React.memo((props: InputBarProps) => { (e: React.ChangeEvent) => { const value = e.target.value; setDraft(value); + onDraftChange(value); if (!value.trim()) { handleStopTyping(); @@ -77,7 +70,7 @@ export const InputBar = React.memo((props: InputBarProps) => { handleStopTyping(); }, 2000); }, - [onStartTyping, handleStopTyping, clearTypingTimeout], + [onStartTyping, handleStopTyping, clearTypingTimeout, onDraftChange], ); return ( @@ -86,16 +79,7 @@ export const InputBar = React.memo((props: InputBarProps) => { value={draft} onChange={handleInputChange} onKeyDown={handleKeyDown} - placeholder={ - isLlmLoading - ? "AI is responding..." - : ready - ? isLlmRoom - ? "Ask the AI..." - : "Type a message..." - : "Connecting..." - } - disabled={isDisabled} + placeholder="Type a message..." rows={1} /> - - )} - - - {chat.ready ? chat.connectionLabel : chat.error || "Connecting..."} - -
- + + )} onEvent("messageSent")} - isLlmLoading={chat.isLlmLoading} - isLlmRoom={isLlmRoom} + onStartTyping={handleStartTyping} + onStopTyping={handleStopTyping} + onDraftChange={handleDraftChange} /> - - setCreateModalOpen(false)} - onCreateRoom={chat.createRoom} - onRoomCreatedEvent={() => onEvent("roomJoined")} - /> - setInviteModalOpen(false)} - currentRoom={chat.currentRoom} - onSendInvite={chat.sendPrivateInvite} - /> ); }); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts index 11777158e..6b1ffd089 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts @@ -1,3 +1,2 @@ -// ChatStore class has been replaced by pluv.io integration. -// All storage, sync, and presence are now managed via @pluv/client + @pluv/react. -// See pluvClient.ts for the pluv setup and ../useChatStore.ts for the React hook. +// Placeholder — all real-time logic is now in ChatControllerV2Comp. +// Data storage is handled by the user's own Data Sources & Queries. diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts index f01dfdf0d..138c7079d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts @@ -1,9 +1,8 @@ export type { ChatMessage, - ChatRoom, - RoomMember, - RoomInvite, TypingUser, + OnlineUser, + MessageBroadcast, } from "./types"; export { uid, LLM_BOT_AUTHOR_ID } from "./types"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts index 94dd953c4..3e5e11ea8 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -1,523 +1,14 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { routeByNameAction, executeQueryAction } from "lowcoder-core"; -import { getPromiseAfterDispatch } from "util/promiseUtils"; -import { - useStorage, - useMyPresence, - useOthers, - useConnection, - LLM_BOT_AUTHOR_ID, - uid, -} from "./store"; -import type { - ChatMessage, - ChatRoom, - RoomMember, - RoomInvite, - TypingUser, -} from "./store"; - -// ── Public interfaces ────────────────────────────────────────────────────── - -export interface UseChatStoreConfig { - userId: string; - userName: string; - dispatch?: (...args: any[]) => void; - systemPrompt?: string; - llmBotName?: string; -} - -export interface PendingRoomInvite extends RoomInvite { - roomName: string; -} - -export interface UseChatStoreReturn { - ready: boolean; - error: string | null; - connectionLabel: string; - isLlmLoading: boolean; - - currentRoom: ChatRoom | null; - messages: ChatMessage[]; - userRooms: ChatRoom[]; - currentRoomMembers: RoomMember[]; - typingUsers: TypingUser[]; - pendingInvites: PendingRoomInvite[]; - - sendMessage: (text: string) => Promise; - switchRoom: (roomId: string) => void; - createRoom: ( - name: string, - type: "public" | "private" | "llm", - description?: string, - llmQueryName?: string, - ) => Promise; - joinRoom: (roomId: string) => boolean; - leaveRoom: (roomId: string) => boolean; - searchRooms: (query: string) => Promise; - sendPrivateInvite: (toUserId: string, toUserName?: string) => Promise; - acceptInvite: (inviteId: string) => boolean; - declineInvite: (inviteId: string) => boolean; - startTyping: () => void; - stopTyping: () => void; -} - -// ── LLM response extraction ─────────────────────────────────────────────── - -function extractAiText(result: any): string { - if (!result) return "No response received."; - if (typeof result === "string") return result; - - if (Array.isArray(result.choices) && result.choices.length > 0) { - const choice = result.choices[0]; - if (choice?.message?.content) return String(choice.message.content); - if (choice?.text) return String(choice.text); - } - if (Array.isArray(result.content) && result.content.length > 0) { - const first = result.content[0]; - if (first?.text) return String(first.text); - } - if (result.message && typeof result.message === "object" && result.message.content) { - return String(result.message.content); - } - if (result.message && typeof result.message === "string") return result.message; - if (result.content && typeof result.content === "string") return result.content; - if (result.text && typeof result.text === "string") return result.text; - if (result.response && typeof result.response === "string") return result.response; - if (result.output && typeof result.output === "string") return result.output; - if (result.answer && typeof result.answer === "string") return result.answer; - if (result.reply && typeof result.reply === "string") return result.reply; - - try { - return JSON.stringify(result, null, 2); - } catch { - return String(result); - } -} - -// ── Hook ─────────────────────────────────────────────────────────────────── - -export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { - const { userId, userName, dispatch, systemPrompt, llmBotName } = config; - - // ── Pluv storage hooks (reactive — re-render on change) ────────────── - const [rooms, roomsYMap] = useStorage("rooms"); - const [members, membersYMap] = useStorage("members"); - const [allMessages, messagesYMap] = useStorage("messages"); - const [invites, invitesYMap] = useStorage("invites"); - - // ── Pluv presence + connection ─────────────────────────────────────── - const [, updateMyPresence] = useMyPresence(); - const others = useOthers(); - const connection = useConnection(); - - // ── Local state ────────────────────────────────────────────────────── - const [activeRoomId, setActiveRoomId] = useState(null); - const [isLlmLoading, setIsLlmLoading] = useState(false); - const [error] = useState(null); - - const activeRoomIdRef = useRef(activeRoomId); - activeRoomIdRef.current = activeRoomId; - - // Keep refs in sync so callbacks see latest values - const dispatchRef = useRef(dispatch); - const systemPromptRef = useRef(systemPrompt); - const llmBotNameRef = useRef(llmBotName); - useEffect(() => { dispatchRef.current = dispatch; }, [dispatch]); - useEffect(() => { systemPromptRef.current = systemPrompt; }, [systemPrompt]); - useEffect(() => { llmBotNameRef.current = llmBotName; }, [llmBotName]); - - // ── Derived state ──────────────────────────────────────────────────── - - const ready = connection.state === "open" && rooms != null; - - const connectionLabel = useMemo(() => { - if (connection.state === "open") return "Online"; - if (connection.state === "connecting") return "Connecting..."; - return "Offline"; - }, [connection.state]); - - const roomsRecord = rooms as Record | null; - const membersRecord = members as Record | null; - const messagesRecord = allMessages as Record | null; - const invitesRecord = invites as Record | null; - - const currentRoom = useMemo(() => { - if (!activeRoomId || !roomsRecord) return null; - return roomsRecord[activeRoomId] ?? null; - }, [roomsRecord, activeRoomId]); - - const userRooms = useMemo(() => { - if (!roomsRecord || !membersRecord) return []; - const memberRoomIds = new Set(); - for (const member of Object.values(membersRecord)) { - if (member.userId === userId) memberRoomIds.add(member.roomId); - } - return Object.values(roomsRecord) - .filter((r) => memberRoomIds.has(r.id)) - .sort((a, b) => b.updatedAt - a.updatedAt); - }, [roomsRecord, membersRecord, userId]); - - const messages = useMemo(() => { - if (!messagesRecord || !activeRoomId) return []; - return Object.values(messagesRecord) - .filter((m) => m.roomId === activeRoomId) - .sort((a, b) => a.timestamp - b.timestamp); - }, [messagesRecord, activeRoomId]); - - const currentRoomMembers = useMemo(() => { - if (!membersRecord || !activeRoomId) return []; - return Object.values(membersRecord) - .filter((m) => m.roomId === activeRoomId) - .sort((a, b) => a.joinedAt - b.joinedAt); - }, [membersRecord, activeRoomId]); - - const typingUsers = useMemo(() => { - if (!activeRoomId) return []; - return others - .filter((o) => { - const t = (o.presence as any)?.typing; - return t?.roomId === activeRoomId && t?.userId !== userId; - }) - .map((o) => (o.presence as any).typing as TypingUser); - }, [others, activeRoomId, userId]); - - const pendingInvites = useMemo(() => { - if (!invitesRecord || !roomsRecord) return []; - return Object.values(invitesRecord) - .filter((inv) => inv.toUserId === userId && inv.status === "pending") - .map((inv) => { - const room = roomsRecord[inv.roomId]; - if (!room || room.type !== "private") return null; - return { ...inv, roomName: room.name }; - }) - .filter((v): v is PendingRoomInvite => v != null) - .sort((a, b) => b.createdAt - a.createdAt); - }, [invitesRecord, roomsRecord, userId]); - - // ── LLM query invocation ───────────────────────────────────────────── - - const invokeLlmQuery = useCallback( - async (queryName: string, userText: string, roomId: string): Promise => { - const currentDispatch = dispatchRef.current; - if (!currentDispatch) { - return "(LLM error: no dispatch available)"; - } - - const rawHistory: Array<{ role: "user" | "assistant"; content: string }> = []; - if (messagesRecord) { - const roomMsgs = Object.values(messagesRecord) - .filter((m) => m.roomId === roomId) - .sort((a, b) => a.timestamp - b.timestamp); - for (const m of roomMsgs) { - rawHistory.push({ - role: m.authorType === "assistant" ? "assistant" : "user", - content: m.text, - }); - } - } - - const sysPrompt = systemPromptRef.current?.trim(); - const conversationHistory = sysPrompt - ? [{ role: "system" as const, content: sysPrompt }, ...rawHistory] - : rawHistory; - - try { - const result: any = await getPromiseAfterDispatch( - currentDispatch, - routeByNameAction( - queryName, - executeQueryAction({ - args: { - prompt: { value: userText }, - message: { value: userText }, - conversationHistory: { value: conversationHistory }, - systemPrompt: { value: sysPrompt ?? "" }, - roomId: { value: roomId }, - }, - }), - ), - ); - return extractAiText(result); - } catch (e: any) { - console.error("[LLM] Query error:", e); - throw new Error(e?.message || "LLM query failed"); - } - }, - [messagesRecord], - ); - - // ── Actions ────────────────────────────────────────────────────────── - - const sendMessage = useCallback( - async (text: string): Promise => { - const roomId = activeRoomIdRef.current; - if (!messagesYMap || !roomsYMap || !roomId || !text.trim()) return false; - - try { - const now = Date.now(); - const msg: ChatMessage = { - id: uid(), - roomId, - authorId: userId, - authorName: userName, - text: text.trim(), - timestamp: now, - authorType: "user", - }; - messagesYMap.set(msg.id, msg); - - const room = roomsYMap.get(roomId) as ChatRoom | undefined; - if (room) { - roomsYMap.set(roomId, { ...room, updatedAt: now }); - } - - if (room?.type === "llm" && room.llmQueryName) { - setIsLlmLoading(true); - try { - const aiText = await invokeLlmQuery(room.llmQueryName, text.trim(), roomId); - const botName = llmBotNameRef.current || "AI Assistant"; - const aiMsg: ChatMessage = { - id: uid(), - roomId, - authorId: LLM_BOT_AUTHOR_ID, - authorName: botName, - text: aiText, - timestamp: Date.now(), - authorType: "assistant", - }; - messagesYMap.set(aiMsg.id, aiMsg); - } catch (e: any) { - const botName = llmBotNameRef.current || "AI Assistant"; - const errMsg: ChatMessage = { - id: uid(), - roomId, - authorId: LLM_BOT_AUTHOR_ID, - authorName: botName, - text: `Sorry, I encountered an error: ${e?.message || "unknown"}`, - timestamp: Date.now(), - authorType: "assistant", - }; - messagesYMap.set(errMsg.id, errMsg); - } finally { - setIsLlmLoading(false); - } - } - - return true; - } catch { - return false; - } - }, - [messagesYMap, roomsYMap, userId, userName, invokeLlmQuery], - ); - - const switchRoom = useCallback( - (roomId: string) => { - if (!roomsRecord) return; - const room = roomsRecord[roomId]; - if (!room) return; - setActiveRoomId(roomId); - setIsLlmLoading(false); - }, - [roomsRecord], - ); - - const createRoom = useCallback( - async ( - name: string, - type: "public" | "private" | "llm", - description?: string, - llmQueryName?: string, - ): Promise => { - if (!roomsYMap || !membersYMap) return null; - const roomId = uid(); - const now = Date.now(); - const room: ChatRoom = { - id: roomId, - name, - description: description || "", - type, - llmQueryName: type === "llm" ? (llmQueryName ?? "") : undefined, - creatorId: userId, - createdAt: now, - updatedAt: now, - }; - roomsYMap.set(roomId, room); - membersYMap.set(`${roomId}::${userId}`, { - roomId, - userId, - userName, - joinedAt: now, - } as RoomMember); - return room; - }, - [roomsYMap, membersYMap, userId, userName], - ); - - const joinRoom = useCallback( - (roomId: string): boolean => { - if (!membersYMap) return false; - const key = `${roomId}::${userId}`; - if ((membersRecord ?? {})[key]) return true; - membersYMap.set(key, { - roomId, - userId, - userName, - joinedAt: Date.now(), - } as RoomMember); - setActiveRoomId(roomId); - return true; - }, - [membersYMap, membersRecord, userId, userName], - ); - - const leaveRoom = useCallback( - (roomId: string): boolean => { - if (!membersYMap) return false; - membersYMap.delete(`${roomId}::${userId}`); - if (activeRoomIdRef.current === roomId) { - const remaining = userRooms.filter((r) => r.id !== roomId); - if (remaining.length > 0) { - setActiveRoomId(remaining[0].id); - } else { - setActiveRoomId(null); - setIsLlmLoading(false); - } - } - return true; - }, - [membersYMap, userId, userRooms], - ); - - const searchRooms = useCallback( - async (query: string): Promise => { - if (!roomsRecord || !membersRecord || !query.trim()) return []; - const memberRoomIds = new Set(); - for (const member of Object.values(membersRecord)) { - if (member.userId === userId) memberRoomIds.add(member.roomId); - } - const lq = query.toLowerCase(); - return Object.values(roomsRecord) - .filter((r) => { - if (r.type === "private") return false; - if (memberRoomIds.has(r.id)) return false; - return ( - r.name.toLowerCase().includes(lq) || - r.description.toLowerCase().includes(lq) - ); - }) - .sort((a, b) => b.updatedAt - a.updatedAt); - }, - [roomsRecord, membersRecord, userId], - ); - - const sendPrivateInvite = useCallback( - async (toUserId: string, toUserName?: string): Promise => { - if (!invitesYMap || !membersRecord) return false; - const room = activeRoomIdRef.current - ? roomsRecord?.[activeRoomIdRef.current] - : null; - const targetId = toUserId.trim(); - if (!room || room.type !== "private" || !targetId) return false; - if (targetId === userId) return false; - - if (membersRecord[`${room.id}::${targetId}`]) return false; - - if (invitesRecord) { - for (const inv of Object.values(invitesRecord)) { - if (inv.roomId === room.id && inv.toUserId === targetId && inv.status === "pending") { - return true; - } - } - } - - const invite: RoomInvite = { - id: uid(), - roomId: room.id, - fromUserId: userId, - fromUserName: userName, - toUserId: targetId, - toUserName: toUserName?.trim() || undefined, - status: "pending", - createdAt: Date.now(), - }; - invitesYMap.set(invite.id, invite); - return true; - }, - [invitesYMap, invitesRecord, membersRecord, roomsRecord, userId, userName], - ); - - const acceptInvite = useCallback( - (inviteId: string): boolean => { - if (!invitesYMap || !membersYMap || !invitesRecord) return false; - const current = invitesRecord[inviteId]; - if (!current || current.toUserId !== userId || current.status !== "pending") return false; - if (!roomsRecord?.[current.roomId]) return false; - - membersYMap.set(`${current.roomId}::${userId}`, { - roomId: current.roomId, - userId, - userName, - joinedAt: Date.now(), - } as RoomMember); - invitesYMap.set(inviteId, { - ...current, - status: "accepted", - respondedAt: Date.now(), - } as RoomInvite); - setActiveRoomId(current.roomId); - return true; - }, - [invitesYMap, membersYMap, invitesRecord, roomsRecord, userId, userName], - ); - - const declineInvite = useCallback( - (inviteId: string): boolean => { - if (!invitesYMap || !invitesRecord) return false; - const current = invitesRecord[inviteId]; - if (!current || current.toUserId !== userId || current.status !== "pending") return false; - invitesYMap.set(inviteId, { - ...current, - status: "declined", - respondedAt: Date.now(), - } as RoomInvite); - return true; - }, - [invitesYMap, invitesRecord, userId], - ); - - const startTyping = useCallback(() => { - const roomId = activeRoomIdRef.current; - if (!roomId) return; - updateMyPresence({ typing: { userId, userName, roomId } }); - }, [updateMyPresence, userId, userName]); - - const stopTyping = useCallback(() => { - updateMyPresence({ typing: null }); - }, [updateMyPresence]); - - return { - ready, - error, - connectionLabel, - isLlmLoading, - currentRoom, - messages, - userRooms, - currentRoomMembers, - typingUsers, - pendingInvites, - sendMessage, - switchRoom, - createRoom, - joinRoom, - leaveRoom, - searchRooms, - sendPrivateInvite, - acceptInvite, - declineInvite, - startTyping, - stopTyping, - }; -} +// ────────────────────────────────────────────────────────────────────────────── +// DEPRECATED — This hook is no longer used. +// +// Architecture change (v2): +// • ChatControllerV2Comp — signal server (Pluv/Yjs) for presence, +// typing, and message-activity broadcasts. +// • ChatBoxV2Comp — pure UI component that receives messages +// from external data queries and fires events. +// +// All Pluv/Yjs logic now lives in ChatControllerV2Comp. +// Data storage is handled by the user's own Data Sources & Queries. +// ────────────────────────────────────────────────────────────────────────────── + +export {}; From c26d9b1d0ec79feaa8f20f1757b5473bbbc55a5d Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 13 Mar 2026 21:43:00 +0500 Subject: [PATCH 30/34] add rooms in the chatbox UI + fix controller issues --- .../comps/comps/chatBoxComponentv2/README.md | 860 +++++++++++++----- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 151 ++- .../components/ChatBoxView.tsx | 193 +++- .../components/RoomPanel.tsx | 255 ++++-- .../chatBoxComponentv2/store/ChatStore.ts | 2 +- .../comps/chatBoxComponentv2/store/index.ts | 2 + .../comps/chatBoxComponentv2/store/types.ts | 21 + .../comps/chatBoxComponentv2/useChatStore.ts | 4 +- .../src/comps/hooks/chatControllerV2Comp.tsx | 85 +- .../lowcoder/src/comps/hooks/hookComp.tsx | 4 +- .../src/comps/hooks/hookCompTypes.tsx | 4 +- client/packages/lowcoder/src/comps/index.tsx | 6 +- .../lowcoder/src/comps/uiCompRegistry.ts | 2 +- .../src/pages/editor/editorConstants.tsx | 2 +- 14 files changed, 1251 insertions(+), 340 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md index c5dcd0459..641fdd7e1 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md @@ -1,16 +1,203 @@ -# Chat V2 — Testing & Setup Guide +# Chat V2 — Complete Reference & Testing Guide ## Architecture -The Chat V2 system separates **real-time signaling** from **data storage**: +The Chat V2 system is split into two Lowcoder components with a clear separation of concerns: | Layer | Component | Responsibility | |-------|-----------|---------------| -| Signal | **Chat Signal Controller** | Pluv/Yjs — presence, typing, message notifications | -| UI | **Chat Box V2** | Pure display — renders messages, fires events | -| Storage | **Your Data Queries** | MongoDB, PostgreSQL, REST API, etc. | +| **Brain** | `Chat Signal Controller` | Pluv/Yjs — presence, typing, message notifications, **native room management** | +| **UI** | `Chat Box V2` | Pure display — rooms panel, messages, input bar, modals | +| **Storage** | Your Data Queries | MongoDB, PostgreSQL, REST API — persists messages (and optionally rooms) | -Pluv/Yjs only broadcasts ephemeral real-time data (who is online, who is typing, "a new message was just saved"). It does **not** store messages — that is your database's job. +``` +┌─────────────────────────────────────────────────────────┐ +│ Chat Box V2 (UI) │ +│ ┌───────────────┐ ┌──────────────────────────────────┐ │ +│ │ Rooms Panel │ │ Chat Area │ │ +│ │ │ │ Header (Room name / title) │ │ +│ │ 🤖 AI Rooms │ │ MessageList │ │ +│ │ 🌐 Public │ │ - User bubbles │ │ +│ │ 🔒 Private │ │ - AI bubbles (with Markdown) │ │ +│ │ │ │ InputBar │ │ +│ └───────────────┘ └──────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + ↕ events / bound props +┌─────────────────────────────────────────────────────────┐ +│ Chat Signal Controller (Brain) │ +│ Pluv/Yjs real-time signal layer │ +│ • Presence (who is online) │ +│ • Typing indicators │ +│ • Message-activity broadcasts │ +│ • Native room CRUD (rooms YMap in Yjs) │ +│ • Invite system (invites YMap in Yjs) │ +└─────────────────────────────────────────────────────────┘ + ↕ Pluv WebSocket +┌─────────────────────────────────────────────────────────┐ +│ Pluv Auth Server │ +│ node pluv-server.js (port 3006) │ +└─────────────────────────────────────────────────────────┘ +``` + +> **Message storage is always your responsibility.** Pluv/Yjs only carries ephemeral real-time data (who is online, typing, new-message notifications). Use any database or API for messages — and optionally for rooms too. + +--- + +## File Structure + +``` +chatBoxComponentv2/ +├── chatBoxComp.tsx # Lowcoder component definition (props, events, exposed state) +├── index.tsx # Public export +├── styles.ts # All styled-components +├── useChatStore.ts # Deprecated (kept for reference) +├── store/ +│ ├── index.ts # Re-exports all public types + Pluv hooks +│ ├── types.ts # TypeScript interfaces: ChatRoom, ChatMessage, etc. +│ └── pluvClient.ts # Pluv client + React bundle (useStorage, useMyPresence, …) +└── components/ + ├── ChatBoxView.tsx # Main view — composes RoomPanel + MessageList + InputBar + ├── MessageList.tsx # Message bubbles, AI bubbles, typing indicator + ├── InputBar.tsx # Textarea + send button + ├── RoomPanel.tsx # Sidebar: room list, search, invites, create button + ├── CreateRoomModal.tsx # Modal: create public / private / LLM room + └── InviteUserModal.tsx # Modal: invite user to a private room + +hooks/ +└── chatControllerV2Comp.tsx # Chat Signal Controller — the "brain" component +``` + +--- + +## Data Structures + +All types are exported from `./store`. + +### `ChatRoom` + +Stored in the Pluv Yjs `rooms` YMap — synced in real-time to all connected users. + +```typescript +interface ChatRoom { + id: string; // auto-generated uid + name: string; // display name + type: "public" | "private" | "llm"; // room visibility / mode + description?: string; // optional subtitle + members: string[]; // array of userId strings + // public → empty (everyone can see/join) + // private → tracked member list + // llm → tracked member list + createdBy?: string; // userId of creator + createdAt?: number; // Unix ms timestamp + llmQueryName?: string; // for "llm" rooms: name of the Lowcoder query to call +} +``` + +**Room type behaviour:** + +| Type | Who can see it | Members array | Invites | +|------|---------------|---------------|---------| +| `public` | Everyone (exposed in `rooms`) | Empty — anyone can join | — | +| `private` | Only listed members (in `userRooms`) | Populated — join-by-invite | ✅ | +| `llm` | Listed members | Populated | ✅ | + +--- + +### `ChatMessage` + +Your database schema — the Chat Box V2 reads these fields flexibly: + +```typescript +interface ChatMessage { + // Preferred field names → fallbacks (any of these will work) + id: string; // or: _id + text: string; // or: message, content + authorId: string; // or: userId, author_id, sender + authorName: string; // or: userName, author_name, senderName + timestamp: number; // or: createdAt, created_at, time (ISO string also works) + + // Optional — controls rendering style + authorType?: "user" | "assistant"; // "assistant" → AI bubble with Markdown + copy button + // Any extra fields pass through and are ignored + [key: string]: any; +} +``` + +Example stored document: + +```json +{ + "id": "1714500000000_abc123xyz", + "roomId": "room_general", + "text": "Hello everyone! 👋", + "authorId": "user_42", + "authorName": "Alice", + "timestamp": 1714500000000 +} +``` + +--- + +### `PendingRoomInvite` + +Stored in the Pluv Yjs `invites` YMap. Auto-filtered per user. + +```typescript +interface PendingRoomInvite { + id: string; // auto-generated uid + roomId: string; // target room + roomName: string; // display name (denormalised for the invite card) + fromUserId: string; // who sent the invite + fromUserName: string; // display name of sender + toUserId: string; // recipient — filtered to show only your invites + timestamp: number; // Unix ms +} +``` + +--- + +### `TypingUser` + +Emitted by the controller via Pluv presence. Scoped to `currentRoomId`. + +```typescript +interface TypingUser { + userId: string; + userName: string; + roomId?: string; // room they are typing in +} +``` + +--- + +### `OnlineUser` + +All connected users sharing the same `applicationId` signal room. + +```typescript +interface OnlineUser { + userId: string; + userName: string; + currentRoomId: string | null; // room they are currently viewing +} +``` + +--- + +### `MessageBroadcast` + +Written to the Pluv `messageActivity` YMap when a user saves a message. Triggers the `newMessageBroadcast` event on all peers. + +```typescript +interface MessageBroadcast { + roomId: string; + messageId: string; + authorId: string; + authorName: string; + timestamp: number; + counter: number; // monotonic counter — used to detect new broadcasts +} +``` --- @@ -18,21 +205,23 @@ Pluv/Yjs only broadcasts ephemeral real-time data (who is online, who is typing, ### 1. Pluv.io Account -Sign up at [pluv.io](https://pluv.io) and create a project. You will need: +Sign up at [pluv.io](https://pluv.io) and create a project. You need: + +- **Publishable Key** (`pk_...`) — goes into the Chat Signal Controller's "Public Key" property +- **Secret Key** (`sk_...`) — stays on the server only -- **Publishable Key** (`pk_...`) — used by the client -- **Secret Key** (`sk_...`) — used by the auth server only +### 2. Pluv Auth Server -### 2. Start the Pluv Auth Server +The auth server mints short-lived tokens for Pluv connections. ```bash cd client/packages/lowcoder -# Set environment variables +# Provide your Pluv keys export PLUV_PUBLISHABLE_KEY="pk_..." export PLUV_SECRET_KEY="sk_..." -# Start the server (defaults to port 3006) +# Start (defaults to port 3006) npm run start:pluv # or directly: node pluv-server.js @@ -43,315 +232,558 @@ Verify it's running: ```bash curl http://localhost:3006/health # → { "status": "healthy", "server": "pluv-chat", ... } + +curl "http://localhost:3006/api/auth/pluv?room=signal_myapp&userId=user_1&userName=Alice" +# → { "token": "..." } ``` --- -## Quick Start — Minimal Chat in 5 Steps +## Quick Start — Full Chat in 5 Steps + +### Step 1 — Add `Chat Signal Controller` + +1. Open **Insert panel** → search **"Chat Signal Controller"** (under Collaboration) +2. Drag onto canvas — it is headless (no visual output, renders nothing) +3. Configure in the right-side property panel: -### Step 1: Add the Chat Signal Controller +| Property | Example value | Notes | +|----------|--------------|-------| +| Application ID | `my_app` | All users with the same ID share presence | +| User ID | `{{ currentUser.id }}` | Unique per user | +| User Name | `{{ currentUser.name }}` | Display name | +| Public Key | `pk_live_...` | From pluv.io dashboard | +| Auth URL | `http://localhost:3006/api/auth/pluv` | Your running auth server | -1. In the Lowcoder editor, open the **Insert** panel -2. Search for **"Chat Signal Controller"** (under Collaboration) -3. Drag it onto the canvas (it's headless — no visual output) -4. Configure in the property panel: +The component is typically named `chatController1` automatically. -| Property | Value | -|----------|-------| -| Application ID | `my_chat_app` (or any string — scopes the signal room) | -| User ID | `{{ currentUser.id }}` or a hardcoded test value like `user_1` | -| User Name | `{{ currentUser.name }}` or `Alice` | -| Public Key | Your Pluv publishable key (`pk_...`) | -| Auth URL | `http://localhost:3006/api/auth/pluv` | +--- -The controller is named `chatControllerV2` by default — you can rename it. +### Step 2 — Add `Chat Box V2` -### Step 2: Add the Chat Box V2 +1. In Insert panel, search **"Chat Box V2"** → drag onto canvas +2. Configure: -1. Search for **"Chat Box V2"** and drag it onto the canvas -2. Configure in the property panel: +| Property | Bind to | Notes | +|----------|---------|-------| +| Messages | `{{ loadMessages.data }}` | Your load query | +| Current User ID | `{{ chatController1.userId }}` | Drives own-vs-other bubble alignment | +| Current User Name | `{{ chatController1.userName }}` | — | +| Typing Users | `{{ chatController1.typingUsers }}` | Typing indicator | +| Rooms | `{{ chatController1.userRooms }}` | Rooms visible to this user | +| Current Room ID | `{{ chatController1.currentRoomId }}` | Highlights active room | +| Pending Invites | `{{ chatController1.pendingInvites }}` | Invite cards in room panel | +| Show Rooms Panel | `true` | Set to `false` to hide the sidebar | -| Property | Value | Purpose | -|----------|-------|---------| -| Chat Title | `Team Chat` | Header display name | -| Messages | `{{ loadMessages.data }}` | Bind to your data query (Step 3) | -| Current User ID | `{{ chatControllerV2.userId }}` | Distinguishes own vs. others' messages | -| Current User Name | `{{ chatControllerV2.userName }}` | Display name | -| Typing Users | `{{ chatControllerV2.typingUsers }}` | Shows typing indicators | +--- -### Step 3: Create Data Queries +### Step 3 — Create Data Queries -You need two queries — one to **load** messages and one to **save** them. Use whatever data source you prefer. +You need at minimum: **loadMessages** and **saveMessage**. -#### Example: MongoDB "loadMessages" query +#### `loadMessages` — MongoDB example ```js -// Query name: loadMessages -// Data source: MongoDB // Collection: chat_messages // Operation: Find // Filter: -{ "roomId": "general" } +{ "roomId": "{{ chatController1.currentRoomId || 'general' }}" } // Sort: { "timestamp": 1 } ``` -#### Example: MongoDB "saveMessage" query +#### `loadMessages` — REST API example + +``` +GET https://your-api.com/messages?roomId={{ chatController1.currentRoomId || 'general' }} +``` + +#### `saveMessage` — MongoDB example ```js -// Query name: saveMessage -// Data source: MongoDB // Collection: chat_messages // Operation: Insert -// Document: { - "id": {{ uuid() }}, - "roomId": "general", - "text": {{ chatBoxV2.lastSentMessageText }}, - "authorId": {{ chatControllerV2.userId }}, - "authorName": {{ chatControllerV2.userName }}, - "timestamp": {{ Date.now() }} + "id": "{{ uid() }}", + "roomId": "{{ chatController1.currentRoomId || 'general' }}", + "text": "{{ chatBox1.lastSentMessageText }}", + "authorId": "{{ chatController1.userId }}", + "authorName": "{{ chatController1.userName }}", + "timestamp": "{{ Date.now() }}" } ``` -#### Example: REST API queries +--- -``` -// loadMessages -GET https://your-api.com/messages?roomId=general - -// saveMessage -POST https://your-api.com/messages -Body: { - "roomId": "general", - "text": {{ chatBoxV2.lastSentMessageText }}, - "authorId": {{ chatControllerV2.userId }}, - "authorName": {{ chatControllerV2.userName }}, - "timestamp": {{ Date.now() }} -} -``` +### Step 4 — Wire Up Events -### Step 4: Wire Up Events +#### On `Chat Box V2` (chatBox1): -#### On the Chat Box V2: +| Event | Actions to run | Notes | +|-------|---------------|-------| +| **Message Sent** | 1. `saveMessage.run()`
2. `chatController1.broadcastNewMessage(chatController1.currentRoomId)`
3. `loadMessages.run()` | Order matters: save → broadcast → reload | +| **Start Typing** | `chatController1.startTyping(chatController1.currentRoomId)` | — | +| **Stop Typing** | `chatController1.stopTyping()` | — | +| **Room Switch** | `chatController1.switchRoom(chatBox1.pendingRoomId)` then `loadMessages.run()` | User clicked a room they're already in | +| **Room Join** | `chatController1.joinRoom(chatBox1.pendingRoomId)` then `loadMessages.run()` | User joined from search | +| **Room Leave** | `chatController1.leaveRoom(chatBox1.pendingRoomId)` | — | +| **Room Create** | `chatController1.createRoom(chatBox1.newRoomName, chatBox1.newRoomType, chatBox1.newRoomDescription, chatBox1.newRoomLlmQuery)` | — | +| **Invite Send** | `chatController1.sendInvite(chatController1.currentRoomId, chatBox1.inviteTargetUserId)` | Private rooms only | +| **Invite Accept** | `chatController1.acceptInvite(chatBox1.pendingInviteId)` then `loadMessages.run()` | — | +| **Invite Decline** | `chatController1.declineInvite(chatBox1.pendingInviteId)` | — | -| Event | Action | -|-------|--------| -| **Message Sent** | 1. Run `saveMessage` query
2. Run `chatControllerV2.broadcastNewMessage("general")`
3. Run `loadMessages` query | -| **Start Typing** | Run `chatControllerV2.startTyping("general")` | -| **Stop Typing** | Run `chatControllerV2.stopTyping()` | +#### On `Chat Signal Controller` (chatController1): -#### On the Chat Signal Controller: +| Event | Actions to run | Notes | +|-------|---------------|-------| +| **New Message Broadcast** | `loadMessages.run()` | A peer saved a message — reload | +| **Connected** | `loadMessages.run()` | Initial load | +| **Room Switched** | `loadMessages.run()` | Active room changed | +| **Room Joined** | `loadMessages.run()` | Joined a new room | -| Event | Action | -|-------|--------| -| **New Message Broadcast** | Run `loadMessages` query (a peer saved a new message) | -| **Connected** | Run `loadMessages` query (initial load) | +--- -### Step 5: Test +### Step 5 — Test -1. Open the app in **two browser tabs** (or two different browsers) -2. Set different User IDs for each tab (e.g. `user_1` / `user_2`) -3. Type in one tab — the other should show a typing indicator -4. Send a message — the other tab should see it appear after the broadcast triggers a reload +1. Open your app in **two browser tabs** (or two different browsers) +2. Ensure each tab has a different User ID +3. Tab A types → Tab B sees the typing indicator +4. Tab A sends → Tab B's `newMessageBroadcast` fires → messages reload --- -## Message Data Format +## Rooms Deep Dive -The Chat Box V2 accepts messages as a JSON array. It reads fields flexibly: +### How Native Rooms Work -| Priority 1 | Priority 2 | Priority 3 | Priority 4 | Purpose | -|------------|------------|------------|------------|---------| -| `id` | `_id` | — | — | Unique key for rendering | -| `text` | `message` | `content` | — | Message body | -| `authorId` | `userId` | `author_id` | `sender` | Author identification | -| `authorName` | `userName` | `author_name` | `senderName` | Display name | -| `timestamp` | `createdAt` | `created_at` | `time` | Time display | +Rooms are stored in a **Yjs YMap** (`rooms`) inside the Pluv signal room. This means: -The `authorType` field (or `role`) with value `"assistant"` renders AI-style bubbles with markdown support and a copy button. +- ✅ Room creation/deletion is instantly synced to all connected users +- ✅ Member lists are updated in real-time +- ✅ No database queries needed just to switch or create rooms +- ⚠️ Rooms are **ephemeral by default** — if you want persistence across sessions, persist them to your database on the `roomCreated` controller event -So if your database uses `sender` instead of `authorId`, it will still work. +### Room Panel UI ---- +The built-in sidebar groups rooms by type: -## Rooms / Channels +``` +Rooms [+] +───────────────────────────── +AI ROOMS + 🤖 GPT Assistant AI +PUBLIC + 🌐 General + 🌐 Announcements +PRIVATE + 🔒 Design Team + 🔒 Backend Squad +───────────────────────────── +[Search public rooms...] +``` -Rooms are **not managed by the components** — they live in your database. The controller and chatbox are room-agnostic; you decide how to filter and organize messages. +- Click a room → fires **Room Switch** event +- Click a room from search → fires **Room Join** event +- Hover active room → leave button (🚪) appears +- `+` button → opens **Create Room** modal +- Invite icon → opens **Invite User** modal (only shown for private rooms) +- Pending invite cards appear above the list with Accept/Decline buttons -### Single Room (Simplest) +### Public Rooms -Hardcode a room ID in your queries: +Visible to everyone in the signal room. No membership tracking. Anyone can join via search. -```js -// loadMessages filter -{ "roomId": "general" } +``` +createRoom("General Chat", "public", "For everyone") ``` -### Multiple Rooms +### Private Rooms -Build a room selector using standard Lowcoder components (Select, List, etc.): +Members-only. The creator is auto-added to the members list. Others join by invite. -1. Create a query to load rooms from your DB -2. Add a **Select** component bound to `{{ loadRooms.data }}` -3. Filter messages by selected room: +``` +createRoom("Backend Team", "private", "Internal discussions") +// Then invite someone: +sendInvite(roomId, "user_99", "Bob") +``` -```js -// loadMessages filter -{ "roomId": {{ roomSelect.value }} } +### LLM / AI Rooms + +A special room type where every user message automatically triggers a Lowcoder query (your AI backend). The response is broadcast to all room members. + +``` +createRoom("GPT Assistant", "llm", "Ask anything", "getAIResponse") ``` -4. When switching rooms, call: +The `llmQueryName` field stores the **exact name of a Lowcoder query** you've created. Your query receives: -```js -chatControllerV2.switchRoom(roomSelect.value) +```json +{ + "prompt": "the user's message text", + "roomId": "the room id", + "conversationHistory": [ ...recent messages array... ] +} ``` -This scopes the typing indicator to the selected room, so users in different rooms don't see each other's typing state. +> **Note:** LLM query invocation from the room context is wired externally via events — the component fires events and you handle the AI response in your query logic. The `llmQueryName` field is stored on the room so the developer knows which query to call. + +### Persisting Rooms to a Database -5. When sending, broadcast with the room ID: +Wire the `roomCreated` event on the controller to your save query: ```js -chatControllerV2.broadcastNewMessage(roomSelect.value) +// On chatController1 → roomCreated event: +saveRoom.run() + +// saveRoom query document: +{ + "id": "{{ chatController1.currentRoomId }}", + "name": "{{ chatController1.rooms.find(r => r.id === chatController1.currentRoomId)?.name }}", + "type": "{{ chatController1.rooms.find(r => r.id === chatController1.currentRoomId)?.type }}", + "createdBy": "{{ chatController1.userId }}", + "createdAt": "{{ Date.now() }}" +} ``` -### Public vs. Private Rooms +--- -Since rooms are in your database, you control access: +## Controller Reference (`chatController1`) -```js -// Public rooms query -{ "type": "public" } +### Properties (read via `{{ chatController1.propertyName }}`) -// Private rooms — only show rooms where the user is a member -{ "type": "private", "members": { "$in": [{{ currentUser.id }}] } } -``` +| Property | Type | Description | +|----------|------|-------------| +| `ready` | `boolean` | `true` when connected to the Pluv signal server | +| `connectionStatus` | `string` | `"Online"` · `"Connecting..."` · `"Offline"` | +| `error` | `string \| null` | Error message from auth or connection failure | +| `userId` | `string` | Current user's ID | +| `userName` | `string` | Current user's display name | +| `applicationId` | `string` | Scope ID — all users sharing this see each other | +| `currentRoomId` | `string \| null` | Currently active room ID | +| `onlineUsers` | `OnlineUser[]` | All users connected to the signal room | +| `typingUsers` | `TypingUser[]` | Users currently typing, scoped to `currentRoomId` | +| `lastMessageNotification` | `MessageBroadcast \| null` | Last broadcast from a peer | +| `rooms` | `ChatRoom[]` | **All** rooms in the Yjs store | +| `userRooms` | `ChatRoom[]` | Rooms visible to this user (all public + private rooms they are a member of) | +| `pendingInvites` | `PendingRoomInvite[]` | Invites addressed to the current user | -There is no built-in room creation UI. Use a **Modal** or **Form** component with your own "createRoom" query. +--- + +### Events (fire on `chatController1`) + +| Event | When fired | Typical action | +|-------|-----------|----------------| +| `connected` | Pluv WebSocket opened | `loadMessages.run()` | +| `disconnected` | Pluv WebSocket closed | Show offline indicator | +| `error` | Auth or connection failure | Show error toast | +| `userJoined` | A peer came online | Update online badge | +| `userLeft` | A peer went offline | Update online badge | +| `newMessageBroadcast` | A peer saved a message | `loadMessages.run()` | +| `roomCreated` | A new room was created | Persist to DB (optional) | +| `roomJoined` | Current user joined a room | `loadMessages.run()` | +| `roomLeft` | Current user left a room | Clear message list | +| `roomSwitched` | Active room changed | `loadMessages.run()` | --- -## Typing Indicators +### Methods (call as `chatController1.methodName(args)`) + +#### Messaging + +| Method | Parameters | Description | +|--------|-----------|-------------| +| `broadcastNewMessage(roomId, messageId?)` | `roomId: string`, `messageId?: string` | Notify all peers a message was saved in `roomId`. Triggers their `newMessageBroadcast` event. | +| `startTyping(roomId?)` | `roomId?: string` | Set this user's typing presence. Optional override — defaults to `currentRoomId`. | +| `stopTyping()` | — | Clear typing presence. | + +#### Identity -Typing indicators work automatically when you wire the events: +| Method | Parameters | Description | +|--------|-----------|-------------| +| `setUser(userId, userName)` | `userId: string`, `userName: string` | Update user identity at runtime. | -1. **Chat Box V2** fires `startTyping` when the user begins typing and `stopTyping` after 2 seconds of inactivity -2. Wire these events to the controller methods: - - `startTyping` → `chatControllerV2.startTyping("roomId")` - - `stopTyping` → `chatControllerV2.stopTyping()` -3. Bind the Chat Box V2's **Typing Users** property to `{{ chatControllerV2.typingUsers }}` +#### Room Management -The typing indicator shows the names of users currently typing, scoped to the controller's `currentRoomId`. If you use `switchRoom()` when changing rooms, typing indicators are automatically scoped. +| Method | Parameters | Description | +|--------|-----------|-------------| +| `switchRoom(roomId)` | `roomId: string` | Set active room context. Updates presence. Fires `roomSwitched`. | +| `createRoom(name, type, description?, llmQueryName?)` | `name: string`, `type: "public"\|"private"\|"llm"`, `description?: string`, `llmQueryName?: string` | Create a new room in Yjs. Creator is auto-joined. Fires `roomCreated`. | +| `joinRoom(roomId)` | `roomId: string` | Add current user to room members + switch to it. Fires `roomJoined`. | +| `leaveRoom(roomId)` | `roomId: string` | Remove current user from room members. Clears `currentRoomId` if it was the active room. Fires `roomLeft`. | +| `deleteRoom(roomId)` | `roomId: string` | Remove the room from Yjs entirely (for all users). | + +#### Invites (Private Rooms) + +| Method | Parameters | Description | +|--------|-----------|-------------| +| `sendInvite(roomId, toUserId, toUserName?)` | `roomId: string`, `toUserId: string`, `toUserName?: string` | Write an invite to the Yjs `invites` YMap. Only works for private rooms. | +| `acceptInvite(inviteId)` | `inviteId: string` | Join the room and delete the invite. | +| `declineInvite(inviteId)` | `inviteId: string` | Delete the invite without joining. | --- -## Online Users +## Chat Box Reference (`chatBox1`) -The controller exposes `{{ chatControllerV2.onlineUsers }}` — an array of: +### Properties (read via `{{ chatBox1.propertyName }}`) -```json -[ - { "userId": "user_1", "userName": "Alice", "currentRoomId": "general" }, - { "userId": "user_2", "userName": "Bob", "currentRoomId": "design" } -] -``` +| Property | Type | Description | +|----------|------|-------------| +| `chatTitle` | `string` | The configured title (shown in header when no room is active) | +| `lastSentMessageText` | `string` | Text of the last message the user sent — use in your save query | +| `messageText` | `string` | Live draft text currently in the input bar | +| `pendingRoomId` | `string` | Room ID the user wants to switch to / join / leave | +| `newRoomName` | `string` | Name from the Create Room form | +| `newRoomType` | `string` | `"public"` · `"private"` · `"llm"` | +| `newRoomDescription` | `string` | Description from the Create Room form | +| `newRoomLlmQuery` | `string` | Query name from the Create Room form (LLM rooms) | +| `inviteTargetUserId` | `string` | User ID entered in the Invite User form | +| `pendingInviteId` | `string` | Invite ID being accepted or declined | + +### Configuration Props + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| Messages | `ChatMessage[]` | `[]` | Bind to `{{ loadMessages.data }}` | +| Current User ID | `string` | `"user_1"` | Bind to `{{ chatController1.userId }}` | +| Current User Name | `string` | `"User"` | — | +| Typing Users | `TypingUser[]` | `[]` | Bind to `{{ chatController1.typingUsers }}` | +| Rooms | `ChatRoom[]` | `[]` | Bind to `{{ chatController1.userRooms }}` | +| Current Room ID | `string` | `""` | Bind to `{{ chatController1.currentRoomId }}` | +| Pending Invites | `PendingRoomInvite[]` | `[]` | Bind to `{{ chatController1.pendingInvites }}` | +| Show Rooms Panel | `boolean` | `true` | Toggle the left sidebar | +| Panel Width | `string` | `"240px"` | CSS width of the sidebar | +| Allow Room Creation | `boolean` | `true` | Show/hide the `+` button | +| Allow Room Search | `boolean` | `true` | Show/hide the search input | +| Show Header | `boolean` | `true` | Show/hide the chat header bar | + +--- + +### Events (fire on `chatBox1`) + +#### Messaging + +| Event | When | Read state | +|-------|------|------------| +| `messageSent` | User presses Enter or Send | `chatBox1.lastSentMessageText` | +| `startTyping` | User starts typing | — | +| `stopTyping` | User is idle for 2 seconds | — | + +#### Room Interactions + +| Event | When | Read state | +|-------|------|------------| +| `roomSwitch` | User clicked a room they are already in | `chatBox1.pendingRoomId` | +| `roomJoin` | User clicked a room from search results | `chatBox1.pendingRoomId` | +| `roomLeave` | User clicked the leave (🚪) icon | `chatBox1.pendingRoomId` | +| `roomCreate` | User submitted the Create Room form | `chatBox1.newRoomName`, `chatBox1.newRoomType`, `chatBox1.newRoomDescription`, `chatBox1.newRoomLlmQuery` | + +#### Invite Interactions -Display this with any Lowcoder component (List, Table, Avatars, etc.): +| Event | When | Read state | +|-------|------|------------| +| `inviteSend` | User submitted the Invite User form | `chatBox1.inviteTargetUserId` | +| `inviteAccept` | User clicked Accept on an invite card | `chatBox1.pendingInviteId` | +| `inviteDecline` | User clicked Decline on an invite card | `chatBox1.pendingInviteId` | + +--- + +## Complete Wiring Cheatsheet ``` -{{ chatControllerV2.onlineUsers.length }} users online +chatController1.userRooms ──────────→ chatBox1.rooms +chatController1.currentRoomId ──────→ chatBox1.currentRoomId +chatController1.typingUsers ────────→ chatBox1.typingUsers +chatController1.pendingInvites ─────→ chatBox1.pendingInvites +chatController1.userId ─────────────→ chatBox1.currentUserId + +Event flow (chatBox1 → chatController1): + +chatBox1[messageSent] → saveMessage.run() + → chatController1.broadcastNewMessage(chatController1.currentRoomId) + → loadMessages.run() + +chatBox1[startTyping] → chatController1.startTyping(chatController1.currentRoomId) +chatBox1[stopTyping] → chatController1.stopTyping() + +chatBox1[roomSwitch] → chatController1.switchRoom(chatBox1.pendingRoomId) + → loadMessages.run() + +chatBox1[roomJoin] → chatController1.joinRoom(chatBox1.pendingRoomId) + → loadMessages.run() + +chatBox1[roomLeave] → chatController1.leaveRoom(chatBox1.pendingRoomId) + +chatBox1[roomCreate] → chatController1.createRoom( + chatBox1.newRoomName, + chatBox1.newRoomType, + chatBox1.newRoomDescription, + chatBox1.newRoomLlmQuery + ) + +chatBox1[inviteSend] → chatController1.sendInvite( + chatController1.currentRoomId, + chatBox1.inviteTargetUserId + ) + +chatBox1[inviteAccept] → chatController1.acceptInvite(chatBox1.pendingInviteId) + → loadMessages.run() + +chatBox1[inviteDecline] → chatController1.declineInvite(chatBox1.pendingInviteId) + +Event flow (chatController1 internal): + +chatController1[connected] → loadMessages.run() +chatController1[newMessageBroadcast]→ loadMessages.run() +chatController1[roomSwitched] → loadMessages.run() +chatController1[roomJoined] → loadMessages.run() ``` --- -## Controller Exposed Properties Reference +## LLM / AI Room Setup -Access these via `{{ chatControllerV2.propertyName }}`: +1. Create a Lowcoder query (e.g. `getAIResponse`) that calls your AI backend +2. The query receives these input arguments: -| Property | Type | Description | -|----------|------|-------------| -| `ready` | `boolean` | Whether the signal server is connected | -| `connectionStatus` | `string` | `"Online"`, `"Connecting..."`, or `"Offline"` | -| `error` | `string \| null` | Error message if connection failed | -| `onlineUsers` | `Array<{ userId, userName, currentRoomId }>` | Currently connected users | -| `typingUsers` | `Array<{ userId, userName, roomId }>` | Users currently typing | -| `currentRoomId` | `string \| null` | Active room set via `switchRoom()` | -| `lastMessageNotification` | `Object \| null` | Last broadcast: `{ roomId, messageId, authorId, authorName, timestamp }` | -| `userId` | `string` | Current user ID | -| `userName` | `string` | Current user name | -| `applicationId` | `string` | Application scope ID | + ```json + { + "prompt": "What is the capital of France?", + "roomId": "room_abc123", + "conversationHistory": [ + { "authorType": "user", "text": "...", "authorId": "user_1" }, + { "authorType": "assistant", "text": "...", "authorId": "__llm_bot__" } + ] + } + ``` -## Controller Methods Reference +3. In the Create Room form in the UI, set **AI Room** mode and enter `getAIResponse` as the query name +4. On `chatBox1[messageSent]`, check if the current room is an LLM room and run the query: -Call these via `chatControllerV2.methodName(args)` in event handlers: + ```js + // Conditional action: + if (chatController1.rooms.find(r => r.id === chatController1.currentRoomId)?.type === 'llm') { + getAIResponse.run(); + } + ``` -| Method | Params | Description | -|--------|--------|-------------| -| `broadcastNewMessage(roomId, messageId?)` | `roomId`: string, `messageId`: string (optional) | Notify all peers a message was saved — triggers their `onNewMessageBroadcast` event | -| `startTyping(roomId?)` | `roomId`: string (optional) | Set typing indicator for current user | -| `stopTyping()` | — | Clear typing indicator | -| `switchRoom(roomId)` | `roomId`: string | Set current room context for presence scoping | -| `setUser(userId, userName)` | `userId`: string, `userName`: string | Update identity at runtime | +5. AI responses should be saved to your messages collection with `authorId: "__llm_bot__"` and `authorType: "assistant"` — the UI will render them with the purple AI bubble and Markdown support -## Chat Box V2 Exposed Properties Reference +--- -Access these via `{{ chatBoxV2.propertyName }}`: +## Local Development & Testing -| Property | Type | Description | -|----------|------|-------------| -| `lastSentMessageText` | `string` | Text of the last message the user sent — use in your save query | -| `messageText` | `string` | Current text in the input (live draft) | -| `chatTitle` | `string` | The configured chat title | +### 1. Start the Pluv Auth Server + +```bash +cd client/packages/lowcoder +export PLUV_PUBLISHABLE_KEY="pk_..." +export PLUV_SECRET_KEY="sk_..." +node pluv-server.js +``` + +### 2. Start the Lowcoder Frontend Dev Server + +```bash +cd client/packages/lowcoder +yarn dev +# or +npm run dev +``` + +### 3. Open the App + +Open `http://localhost:3000` (or your configured dev port) in two browser tabs. -## Chat Box V2 Events Reference +### 4. Minimal Smoke Test (No Database) -| Event | When | Typical action | -|-------|------|----------------| -| `messageSent` | User presses Enter or Send | Run save query, broadcast, reload messages | -| `startTyping` | User begins typing | `chatControllerV2.startTyping(roomId)` | -| `stopTyping` | User idle for 2s | `chatControllerV2.stopTyping()` | +You can test real-time features without a database by using static messages: + +- Set `chatBox1.messages` to a static JSON array in the property panel: + ```json + [ + { "id": "1", "text": "Hello!", "authorId": "user_1", "authorName": "Alice", "timestamp": 1714500000000 }, + { "id": "2", "text": "Hey there!", "authorId": "user_2", "authorName": "Bob", "timestamp": 1714500001000 } + ] + ``` +- This lets you verify presence, typing, and room switching without a live database + +### 5. Full Stack Test + +| What to test | How | +|-------------|-----| +| Pluv connection | `{{ chatController1.connectionStatus }}` shows `"Online"` | +| Presence | Open 2 tabs → `{{ chatController1.onlineUsers }}` shows both users | +| Typing | Tab A types → Tab B sees typing indicator below message list | +| Room creation | Click `+` in rooms panel → fill form → room appears in both tabs | +| Room search | Type in search box → public rooms filter live (client-side) | +| Private invite | Create a private room in Tab A → invite Tab B's userId → Tab B sees invite card | +| Invite accept | Tab B clicks Accept → both tabs see Tab B in the room's members | +| Message broadcast | Tab A sends message → Tab B's `newMessageBroadcast` fires → messages reload | +| LLM room | Create an LLM room → sending a message triggers your AI query | --- ## Testing Checklist -### Basic messaging -- [ ] Start pluv-server (`node pluv-server.js`) -- [ ] Add Chat Signal Controller with valid Pluv keys and Auth URL -- [ ] Add Chat Box V2 with messages bound to a data query -- [ ] Verify `chatControllerV2.ready` shows `true` -- [ ] Verify `chatControllerV2.connectionStatus` shows `"Online"` -- [ ] Send a message — `lastSentMessageText` updates -- [ ] Message appears in your database -- [ ] Message appears in the chat after reload - -### Real-time sync (two browser tabs) -- [ ] Tab A sends a message → Tab B's `onNewMessageBroadcast` fires → messages reload +### Infrastructure +- [ ] Pluv auth server running on port 3006 +- [ ] `curl http://localhost:3006/health` returns `{"status":"healthy",...}` +- [ ] Lowcoder dev server running + +### Controller Setup +- [ ] `chatController1.connectionStatus` shows `"Online"` +- [ ] `chatController1.ready` is `true` +- [ ] `chatController1.userId` and `userName` are set correctly + +### Messaging (single tab) +- [ ] Type a message → `chatBox1.messageText` updates live +- [ ] Send → `chatBox1.lastSentMessageText` holds the sent text +- [ ] `messageSent` event fires +- [ ] Save query runs successfully +- [ ] `broadcastNewMessage` called +- [ ] Messages reload + +### Real-time (two tabs) +- [ ] Tab A online → `onlineUsers` in Tab B shows Tab A +- [ ] Tab A closes → `userLeft` fires in Tab B - [ ] Tab A types → Tab B sees typing indicator -- [ ] Tab A stops typing (2s idle) → indicator disappears -- [ ] Tab B sees Tab A in `onlineUsers` -- [ ] Tab A closes → Tab B's `userLeft` event fires - -### Multi-room -- [ ] Switch rooms via `chatControllerV2.switchRoom(roomId)` -- [ ] Messages filter to the selected room -- [ ] Typing indicators scope to the current room -- [ ] Broadcasting targets the correct room - -### Error handling -- [ ] Invalid Pluv key → `error` event fires, `error` property set -- [ ] Pluv server down → `connectionStatus` shows `"Offline"`, `disconnected` event fires -- [ ] Server comes back → `connected` event fires, status returns to `"Online"` +- [ ] Tab A stops typing (2s idle) → indicator disappears in Tab B +- [ ] Tab A sends message → Tab B's `newMessageBroadcast` fires → messages reload +- [ ] Both tabs show the same rooms list + +### Rooms +- [ ] Click `+` → Create Room modal opens +- [ ] Create a **public** room → appears in both tabs immediately +- [ ] Create a **private** room → only appears for creator +- [ ] Search finds public rooms +- [ ] Join from search → user added to members +- [ ] Leave room → user removed from members +- [ ] Switching rooms updates `currentRoomId` in controller +- [ ] Message load query filters to the correct room + +### Invites +- [ ] Tab A invites Tab B to a private room → Tab B sees invite card +- [ ] Tab B accepts → Tab B is now in the room, invite disappears +- [ ] Tab B declines → invite disappears, Tab B not in the room + +### LLM Room +- [ ] Create LLM room with a valid query name +- [ ] Send message → your AI query fires +- [ ] AI response saved with `authorType: "assistant"` → purple bubble renders --- ## Troubleshooting -| Symptom | Check | -|---------|-------| -| `connectionStatus` stuck on `"Connecting..."` | Verify pluv-server is running and Auth URL is correct | -| Auth fails | Check browser console for `[ChatControllerV2] Auth failed` — verify Pluv keys match | -| Messages don't appear | Check your `loadMessages` query returns the correct format | -| Typing not showing | Verify `typingUsers` is bound to `{{ chatControllerV2.typingUsers }}` and events are wired | -| Broadcasts not received | Ensure both users have the same `applicationId` | -| Own messages show as "other" | Check `currentUserId` matches the `authorId` in your message data | +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| `connectionStatus` stuck at `"Connecting..."` | Auth server not running or wrong URL | Verify `node pluv-server.js` is running; check Auth URL property | +| `Auth failed` in console | Wrong Pluv keys | Check `pk_...` matches the project in pluv.io dashboard | +| Rooms list empty | Not bound to `userRooms` | Set chatBox1.rooms to `{{ chatController1.userRooms }}` | +| Private room not visible | User not in members | Accept an invite or `joinRoom()` | +| Typing indicator not showing | Typing events not wired | Wire `startTyping` → `chatController1.startTyping()` | +| Messages don't reload on peer send | Broadcast event not wired | Wire `newMessageBroadcast` → `loadMessages.run()` on the controller | +| Own messages appear as "other" | Wrong currentUserId | Bind `chatBox1.currentUserId` to `{{ chatController1.userId }}` | +| AI bubble not rendering | `authorType` missing | Save AI messages with `authorType: "assistant"` or `authorId: "__llm_bot__"` | +| Rooms disappear on refresh | Yjs rooms are ephemeral | Persist rooms to DB on `roomCreated` event | +| Invite not received | Pluv not connected | Both users must be in the same `applicationId` signal room | diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index f2a38a750..ae3576a5f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -42,22 +42,82 @@ const ChatEvents = [ description: "Triggered when the user stops typing. Wire this to chatController.stopTyping().", }, + { + label: "Room Switch", + value: "roomSwitch", + description: + "User clicked a room they are already a member of. Read chatBox.pendingRoomId, then call chatController.switchRoom({{chatBox1.pendingRoomId}}).", + }, + { + label: "Room Join", + value: "roomJoin", + description: + "User wants to join a room from search results. Read chatBox.pendingRoomId, then call chatController.joinRoom({{chatBox1.pendingRoomId}}).", + }, + { + label: "Room Leave", + value: "roomLeave", + description: + "User clicked leave on a room. Read chatBox.pendingRoomId, then call chatController.leaveRoom({{chatBox1.pendingRoomId}}).", + }, + { + label: "Room Create", + value: "roomCreate", + description: + "User submitted the create-room form. Read chatBox.newRoomName, newRoomType, newRoomDescription, newRoomLlmQuery, then call chatController.createRoom(...).", + }, + { + label: "Invite Send", + value: "inviteSend", + description: + "User sent a room invite. Read chatBox.inviteTargetUserId, then call chatController.sendInvite(currentRoomId, {{chatBox1.inviteTargetUserId}}).", + }, + { + label: "Invite Accept", + value: "inviteAccept", + description: + "User accepted a pending invite. Read chatBox.pendingInviteId, then call chatController.acceptInvite({{chatBox1.pendingInviteId}}).", + }, + { + label: "Invite Decline", + value: "inviteDecline", + description: + "User declined a pending invite. Read chatBox.pendingInviteId, then call chatController.declineInvite({{chatBox1.pendingInviteId}}).", + }, ] as const; // ─── Children map ──────────────────────────────────────────────────────────── const childrenMap = { + // ── Chat content ───────────────────────────────────────────────── chatTitle: stringExposingStateControl("chatTitle", "Chat"), showHeader: withDefault(BoolControl, true), - messages: jsonArrayControl([]), currentUserId: withDefault(StringControl, "user_1"), currentUserName: withDefault(StringControl, "User"), typingUsers: jsonArrayControl([]), - lastSentMessageText: stringExposingStateControl("lastSentMessageText", ""), messageText: stringExposingStateControl("messageText", ""), + // ── Rooms panel ────────────────────────────────────────────────── + rooms: jsonArrayControl([]), + currentRoomId: withDefault(StringControl, ""), + pendingInvites: jsonArrayControl([]), + showRoomsPanel: withDefault(BoolControl, true), + roomsPanelWidth: withDefault(StringControl, "240px"), + allowRoomCreation: withDefault(BoolControl, true), + allowRoomSearch: withDefault(BoolControl, true), + + // ── Exposed state written on user interactions ──────────────────── + pendingRoomId: stringExposingStateControl("pendingRoomId", ""), + newRoomName: stringExposingStateControl("newRoomName", ""), + newRoomType: stringExposingStateControl("newRoomType", "public"), + newRoomDescription: stringExposingStateControl("newRoomDescription", ""), + newRoomLlmQuery: stringExposingStateControl("newRoomLlmQuery", ""), + inviteTargetUserId: stringExposingStateControl("inviteTargetUserId", ""), + pendingInviteId: stringExposingStateControl("pendingInviteId", ""), + + // ── Style / layout ──────────────────────────────────────────────── autoHeight: AutoHeightControl, onEvent: eventHandlerControl(ChatEvents), style: styleControl(TextStyle, "style"), @@ -93,6 +153,31 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => { })}
+
+ {children.showRoomsPanel.propertyView({ label: "Show Rooms Panel" })} + {children.roomsPanelWidth.propertyView({ + label: "Panel Width", + tooltip: "Width of the rooms sidebar, e.g. 240px or 30%", + })} + {children.rooms.propertyView({ + label: "Rooms", + tooltip: + "Bind to {{ chatController1.userRooms }} — the list of rooms visible to the current user.", + })} + {children.currentRoomId.propertyView({ + label: "Current Room ID", + tooltip: + "Bind to {{ chatController1.currentRoomId }} to highlight the active room.", + })} + {children.pendingInvites.propertyView({ + label: "Pending Invites", + tooltip: + "Bind to {{ chatController1.pendingInvites }} to show invite notifications.", + })} + {children.allowRoomCreation.propertyView({ label: "Allow Room Creation" })} + {children.allowRoomSearch.propertyView({ label: "Allow Room Search" })} +
+
{children.typingUsers.propertyView({ label: "Typing Users", @@ -148,6 +233,46 @@ let ChatBoxV2Tmp = (function () { style={props.style} animationStyle={props.animationStyle} onEvent={props.onEvent} + // Rooms panel + rooms={props.rooms} + currentRoomId={props.currentRoomId} + pendingInvites={props.pendingInvites} + showRoomsPanel={props.showRoomsPanel} + roomsPanelWidth={props.roomsPanelWidth} + allowRoomCreation={props.allowRoomCreation} + allowRoomSearch={props.allowRoomSearch} + // Callbacks that set state then fire events + onRoomSwitch={(roomId) => { + props.pendingRoomId.onChange(roomId); + props.onEvent("roomSwitch"); + }} + onRoomJoin={(roomId) => { + props.pendingRoomId.onChange(roomId); + props.onEvent("roomJoin"); + }} + onRoomLeave={(roomId) => { + props.pendingRoomId.onChange(roomId); + props.onEvent("roomLeave"); + }} + onRoomCreate={(name, type, description, llmQueryName) => { + props.newRoomName.onChange(name); + props.newRoomType.onChange(type); + props.newRoomDescription.onChange(description || ""); + props.newRoomLlmQuery.onChange(llmQueryName || ""); + props.onEvent("roomCreate"); + }} + onInviteSend={(toUserId) => { + props.inviteTargetUserId.onChange(toUserId); + props.onEvent("inviteSend"); + }} + onInviteAccept={(inviteId) => { + props.pendingInviteId.onChange(inviteId); + props.onEvent("inviteAccept"); + }} + onInviteDecline={(inviteId) => { + props.pendingInviteId.onChange(inviteId); + props.onEvent("inviteDecline"); + }} /> ); }) @@ -170,5 +295,27 @@ export const ChatBoxV2Comp = withExposingConfigs(ChatBoxV2Tmp, [ "Text of the last message sent by the user — use in your save query", ), new NameConfig("messageText", "Current text in the message input"), + new NameConfig( + "pendingRoomId", + "Room ID the user wants to switch to, join, or leave — read in roomSwitch/roomJoin/roomLeave events", + ), + new NameConfig("newRoomName", "Name entered in the create-room form"), + new NameConfig( + "newRoomType", + "Type selected in the create-room form: public | private | llm", + ), + new NameConfig("newRoomDescription", "Description entered in the create-room form"), + new NameConfig( + "newRoomLlmQuery", + "Query name entered for LLM rooms in the create-room form", + ), + new NameConfig( + "inviteTargetUserId", + "User ID entered in the invite form — read in inviteSend event", + ), + new NameConfig( + "pendingInviteId", + "Invite ID the user accepted or declined — read in inviteAccept/inviteDecline events", + ), NameConfigHidden, ]); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx index 928b5ae3d..3b8a35130 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { Wrapper, ChatPanelContainer, @@ -6,6 +6,10 @@ import { } from "../styles"; import { MessageList } from "./MessageList"; import { InputBar } from "./InputBar"; +import { RoomPanel } from "./RoomPanel"; +import { CreateRoomModal } from "./CreateRoomModal"; +import { InviteUserModal } from "./InviteUserModal"; +import type { ChatRoom, PendingRoomInvite } from "../store"; export interface ChatBoxViewProps { chatTitle: { value: string; onChange: (v: string) => void }; @@ -18,7 +22,42 @@ export interface ChatBoxViewProps { messageText: { value: string; onChange: (v: string) => void }; style: any; animationStyle: any; - onEvent: (event: "messageSent" | "startTyping" | "stopTyping") => any; + onEvent: ( + event: + | "messageSent" + | "startTyping" + | "stopTyping" + | "roomSwitch" + | "roomJoin" + | "roomLeave" + | "roomCreate" + | "inviteSend" + | "inviteAccept" + | "inviteDecline", + ) => any; + + // Rooms panel + rooms: any; + currentRoomId: string; + pendingInvites: any; + showRoomsPanel: boolean; + roomsPanelWidth: string; + allowRoomCreation: boolean; + allowRoomSearch: boolean; + + // Interaction callbacks + onRoomSwitch: (roomId: string) => void; + onRoomJoin: (roomId: string) => void; + onRoomLeave: (roomId: string) => void; + onRoomCreate: ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ) => void; + onInviteSend: (toUserId: string) => void; + onInviteAccept: (inviteId: string) => void; + onInviteDecline: (inviteId: string) => void; } export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { @@ -33,11 +72,39 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { style, animationStyle, onEvent, + rooms, + currentRoomId, + pendingInvites, + showRoomsPanel, + roomsPanelWidth, + allowRoomCreation, + allowRoomSearch, + onRoomSwitch, + onRoomJoin, + onRoomLeave, + onRoomCreate, + onInviteSend, + onInviteAccept, + onInviteDecline, } = props; + const [createModalOpen, setCreateModalOpen] = useState(false); + const [inviteModalOpen, setInviteModalOpen] = useState(false); + const normalizedMessages = Array.isArray(messages) ? messages : []; const normalizedTypingUsers = Array.isArray(typingUsers) ? typingUsers : []; + const normalizedRooms = Array.isArray(rooms) ? (rooms as ChatRoom[]) : []; + const normalizedInvites = Array.isArray(pendingInvites) + ? (pendingInvites as PendingRoomInvite[]) + : []; + // ── Current room object ──────────────────────────────────────────── + const currentRoom = useMemo( + () => normalizedRooms.find((r) => r.id === currentRoomId) ?? null, + [normalizedRooms, currentRoomId], + ); + + // ── Message handlers ─────────────────────────────────────────────── const handleSend = useCallback( (text: string) => { lastSentMessageText.onChange(text); @@ -61,14 +128,119 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { [messageText], ); + // ── Room panel handlers ──────────────────────────────────────────── + const handleSwitchRoom = useCallback( + (roomId: string) => { + onRoomSwitch(roomId); + }, + [onRoomSwitch], + ); + + const handleJoinRoom = useCallback( + (roomId: string) => { + onRoomJoin(roomId); + }, + [onRoomJoin], + ); + + const handleLeaveRoom = useCallback( + (roomId: string) => { + onRoomLeave(roomId); + }, + [onRoomLeave], + ); + + // Client-side search: public rooms matching query + const handleSearchRooms = useCallback( + async (query: string): Promise => { + if (!query.trim()) return []; + const q = query.toLowerCase(); + return normalizedRooms.filter( + (r) => r.type === "public" && r.name.toLowerCase().includes(q), + ); + }, + [normalizedRooms], + ); + + // ── Create room modal ────────────────────────────────────────────── + const handleCreateRoom = useCallback( + async ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ): Promise => { + onRoomCreate(name, type, description, llmQueryName); + // Return a placeholder so the modal closes immediately; + // the real room will appear via the controller's Pluv sync. + const placeholder: ChatRoom = { + id: "__pending__", + name, + type, + description: description || null, + members: [currentUserId], + createdBy: currentUserId, + createdAt: Date.now(), + llmQueryName: llmQueryName || null, + }; + return placeholder; + }, + [onRoomCreate, currentUserId], + ); + + // ── Invite modal handlers ────────────────────────────────────────── + const handleSendInvite = useCallback( + async (toUserId: string): Promise => { + onInviteSend(toUserId); + return true; + }, + [onInviteSend], + ); + + // ── Derive chat header label ─────────────────────────────────────── + const headerTitle = currentRoom + ? currentRoom.name + : chatTitle.value; + return ( + {/* ── Rooms sidebar ───────────────────────────────────────── */} + {showRoomsPanel && ( + onInviteAccept(id)} + onDeclineInvite={(id) => onInviteDecline(id)} + onCreateModalOpen={() => setCreateModalOpen(true)} + onInviteModalOpen={ + currentRoom?.type === "private" + ? () => setInviteModalOpen(true) + : undefined + } + /> + )} + + {/* ── Chat area ───────────────────────────────────────────── */} {showHeader && (
- {chatTitle.value} + {headerTitle}
+ {currentRoom?.description && ( +
+ {currentRoom.description} +
+ )}
)} @@ -85,6 +257,21 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { onDraftChange={handleDraftChange} />
+ + {/* ── Modals ──────────────────────────────────────────────── */} + setCreateModalOpen(false)} + onCreateRoom={handleCreateRoom} + onRoomCreatedEvent={() => {/* event already fired inside handleCreateRoom */}} + /> + + setInviteModalOpen(false)} + currentRoom={currentRoom} + onSendInvite={handleSendInvite} + />
); }); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx index 964a6de43..6ae07bb8d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx @@ -8,9 +8,9 @@ import { LogoutOutlined, RobotOutlined, MailOutlined, + UserAddOutlined, } from "@ant-design/icons"; -import type { ChatRoom } from "../store"; -import type { PendingRoomInvite } from "../useChatStore"; +import type { ChatRoom, PendingRoomInvite } from "../store"; import { RoomPanelContainer, RoomPanelHeader, @@ -35,6 +35,7 @@ export interface RoomPanelProps { onAcceptInvite: (inviteId: string) => void; onDeclineInvite: (inviteId: string) => void; onCreateModalOpen: () => void; + onInviteModalOpen?: () => void; } export const RoomPanel = React.memo((props: RoomPanelProps) => { @@ -53,6 +54,7 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { onAcceptInvite, onDeclineInvite, onCreateModalOpen, + onInviteModalOpen, } = props; const [searchQuery, setSearchQuery] = useState(""); @@ -90,20 +92,111 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { const roomListItems = isSearchMode ? searchResults : rooms; + // Group rooms by type for display + const publicRooms = roomListItems.filter((r) => r.type === "public"); + const privateRooms = roomListItems.filter((r) => r.type === "private"); + const llmRooms = roomListItems.filter((r) => r.type === "llm"); + + const renderRoomItem = (room: ChatRoom) => { + const isActive = currentRoomId === room.id; + const isSearch = isSearchMode; + + return ( + { + if (isSearch) { + handleJoinAndClear(room.id); + } else if (!isActive) { + onSwitchRoom(room.id); + } + }} + title={isSearch ? `Join "${room.name}"` : room.name} + > + {room.type === "llm" ? ( + + ) : room.type === "public" ? ( + + ) : ( + + )} + + {room.name} + + {room.type === "llm" && !isSearch && ( + + AI + + )} + {isSearch && Join} + {isActive && !isSearch && ( + { + e?.stopPropagation(); + onLeaveRoom(room.id); + }} + onCancel={(e) => e?.stopPropagation()} + okText="Leave" + cancelText="Cancel" + okButtonProps={{ danger: true }} + > + e.stopPropagation()} + style={{ fontSize: 12, opacity: 0.7 }} + /> + + )} + + ); + }; + return ( Rooms - {allowRoomCreation && ( - - - @@ -182,75 +290,66 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { {roomListItems.length === 0 && !isSearchMode && ready && ( -
- No rooms yet. Create or search for one. +
+ No rooms yet. + {allowRoomCreation ? " Create one!" : ""}
)} - {roomListItems.map((room) => { - const isActive = currentRoomId === room.id; - const isSearch = isSearchMode; - - return ( - { - if (isSearch) { - handleJoinAndClear(room.id); - } else if (!isActive) { - onSwitchRoom(room.id); - } - }} - title={isSearch ? `Join "${room.name}"` : room.name} - > - {room.type === "llm" ? ( - - ) : room.type === "public" ? ( - - ) : ( - + {/* Render grouped when not in search mode */} + {isSearchMode + ? roomListItems.map(renderRoomItem) + : ( + <> + {llmRooms.length > 0 && ( + <> + + {llmRooms.map(renderRoomItem)} + )} - - {room.name} - - {room.type === "llm" && !isSearch && ( - - AI - + {publicRooms.length > 0 && ( + <> + + {publicRooms.map(renderRoomItem)} + )} - {isSearch && Join} - {isActive && !isSearch && ( - { - e?.stopPropagation(); - onLeaveRoom(room.id); - }} - onCancel={(e) => e?.stopPropagation()} - okText="Leave" - cancelText="Cancel" - okButtonProps={{ danger: true }} - > - e.stopPropagation()} - style={{ fontSize: 12, opacity: 0.7 }} - /> - + {privateRooms.length > 0 && ( + <> + + {privateRooms.map(renderRoomItem)} + )} - - ); - })} + + )} ); }); RoomPanel.displayName = "RoomPanel"; + +// ── Section label ───────────────────────────────────────────────────────────── + +const RoomSectionLabel = React.memo(({ label }: { label: string }) => ( +
+ {label} +
+)); + +RoomSectionLabel.displayName = "RoomSectionLabel"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts index 6b1ffd089..f3a8fa1ea 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts @@ -1,2 +1,2 @@ -// Placeholder — all real-time logic is now in ChatControllerV2Comp. +// Placeholder — all real-time logic is now in ChatControllerSignal. // Data storage is handled by the user's own Data Sources & Queries. diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts index 138c7079d..c4fecaaf9 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts @@ -1,5 +1,7 @@ export type { ChatMessage, + ChatRoom, + PendingRoomInvite, TypingUser, OnlineUser, MessageBroadcast, diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts index a0a410435..2562d38d8 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts @@ -8,6 +8,27 @@ export interface ChatMessage { [key: string]: any; } +export interface ChatRoom { + id: string; + name: string; + type: "public" | "private" | "llm"; + description: string | null; + members: string[]; + createdBy: string; + createdAt: number; + llmQueryName: string | null; +} + +export interface PendingRoomInvite { + id: string; + roomId: string; + roomName: string; + fromUserId: string; + fromUserName: string; + toUserId: string; + timestamp: number; +} + export interface TypingUser { userId: string; userName: string; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts index 3e5e11ea8..98ed240d5 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -2,12 +2,12 @@ // DEPRECATED — This hook is no longer used. // // Architecture change (v2): -// • ChatControllerV2Comp — signal server (Pluv/Yjs) for presence, +// • ChatControllerSignal — signal server (Pluv/Yjs) for presence, // typing, and message-activity broadcasts. // • ChatBoxV2Comp — pure UI component that receives messages // from external data queries and fires events. // -// All Pluv/Yjs logic now lives in ChatControllerV2Comp. +// All Pluv/Yjs logic now lives in ChatControllerSignal. // Data storage is handled by the user's own Data Sources & Queries. // ────────────────────────────────────────────────────────────────────────────── diff --git a/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx index 739260f0b..d481d1cfe 100644 --- a/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx +++ b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx @@ -46,6 +46,11 @@ const ChatControllerEvents = [ value: "userLeft", description: "A user went offline", }, + { + label: "Room Switched", + value: "roomSwitched", + description: "Active room changed. Read currentRoomId.", + }, { label: "Connected", value: "connected", @@ -109,7 +114,12 @@ const SignalController = React.memo( const others = useOthers(); const [messageActivity, messageActivityYMap] = useStorage("messageActivity"); + const compRef = useRef(comp); + compRef.current = comp; + const triggerEvent = comp.children.onEvent.getView(); + const triggerEventRef = useRef(triggerEvent); + triggerEventRef.current = triggerEvent; const prevRef = useRef<{ ready: boolean; @@ -132,13 +142,13 @@ const SignalController = React.memo( }, [connection.state]); useEffect(() => { - comp.children.ready.dispatchChangeValueAction(ready); - comp.children.connectionStatus.dispatchChangeValueAction(connectionLabel); + compRef.current.children.ready.dispatchChangeValueAction(ready); + compRef.current.children.connectionStatus.dispatchChangeValueAction(connectionLabel); if (ready && !prevRef.current.ready) { - triggerEvent("connected"); + triggerEventRef.current("connected"); } if (!ready && prevRef.current.ready) { - triggerEvent("disconnected"); + triggerEventRef.current("disconnected"); } prevRef.current.ready = ready; }, [ready, connectionLabel]); @@ -155,14 +165,14 @@ const SignalController = React.memo( }, [others]); useEffect(() => { - comp.children.onlineUsers.dispatchChangeValueAction( + compRef.current.children.onlineUsers.dispatchChangeValueAction( onlineUsers as unknown as JSONObject[], ); if (prevRef.current.initialized) { if (onlineUsers.length > prevRef.current.onlineCount) { - triggerEvent("userJoined"); + triggerEventRef.current("userJoined"); } else if (onlineUsers.length < prevRef.current.onlineCount) { - triggerEvent("userLeft"); + triggerEventRef.current("userLeft"); } } prevRef.current.onlineCount = onlineUsers.length; @@ -189,7 +199,7 @@ const SignalController = React.memo( }, [others, currentRoomId, userId]); useEffect(() => { - comp.children.typingUsers.dispatchChangeValueAction( + compRef.current.children.typingUsers.dispatchChangeValueAction( typingUsers as unknown as JSONObject[], ); }, [typingUsers]); @@ -205,16 +215,17 @@ const SignalController = React.memo( if (activity.counter > prevCounter) { prevRef.current.lastBroadcastCounter[roomId] = activity.counter; if (activity.authorId !== userId) { - comp.children.lastMessageNotification.dispatchChangeValueAction( + compRef.current.children.lastMessageNotification.dispatchChangeValueAction( activity as unknown as JSONObject, ); - triggerEvent("newMessageBroadcast"); + triggerEventRef.current("newMessageBroadcast"); } } } }, [messageActivity, userId]); // ── Actions for method invocation ───────────────────────────────── + const broadcastNewMessage = useCallback( (roomId: string, messageId?: string) => { if (!messageActivityYMap) return; @@ -258,28 +269,43 @@ const SignalController = React.memo( const switchRoom = useCallback( (roomId: string) => { - comp.children.currentRoomId.dispatchChangeValueAction(roomId); + compRef.current.children.currentRoomId.dispatchChangeValueAction(roomId); setMyPresence({ userId, userName, currentRoomId: roomId, typing: false, } as any); + triggerEventRef.current("roomSwitched"); }, - [setMyPresence, userId, userName, comp], + [setMyPresence, userId, userName], ); + // ── Proxy ref for stable callbacks ──────────────────────────────── + const actionsRef = useRef({ + broadcastNewMessage, + startTyping, + stopTyping, + switchRoom, + }); + actionsRef.current = { + broadcastNewMessage, + startTyping, + stopTyping, + switchRoom, + }; + useEffect(() => { - const actions: SignalActions = { - broadcastNewMessage, - startTyping, - stopTyping, - switchRoom, + const proxy: SignalActions = { + broadcastNewMessage: (...args) => actionsRef.current.broadcastNewMessage(...args), + startTyping: (...args) => actionsRef.current.startTyping(...args), + stopTyping: () => actionsRef.current.stopTyping(), + switchRoom: (...args) => actionsRef.current.switchRoom(...args), }; - comp.children._signalActions.dispatchChangeValueAction( - actions as unknown as JSONObject, + compRef.current.children._signalActions.dispatchChangeValueAction( + proxy as unknown as JSONObject, ); - }, [broadcastNewMessage, startTyping, stopTyping, switchRoom]); + }, []); // ── Set initial presence ────────────────────────────────────────── useEffect(() => { @@ -289,7 +315,7 @@ const SignalController = React.memo( currentRoomId: null, typing: false, } as any); - }, [userId, userName]); + }, [setMyPresence, userId, userName]); return null; }, @@ -299,7 +325,7 @@ SignalController.displayName = "SignalController"; // ─── View function (wraps PluvRoomProvider) ────────────────────────────────── -const ChatControllerV2Base = withViewFn( +const ChatControllerSignalBase = withViewFn( simpleMultiComp(childrenMap), (comp) => { const userId = comp.children.userId.getView().value; @@ -347,8 +373,8 @@ const ChatControllerV2Base = withViewFn( // ─── Property panel ───────────────────────────────────────────────────────── -const ChatControllerV2WithProps = withPropertyViewFn( - ChatControllerV2Base, +const ChatControllerSignalWithProps = withPropertyViewFn( + ChatControllerSignalBase, (comp) => ( <>
@@ -387,11 +413,8 @@ const ChatControllerV2WithProps = withPropertyViewFn( // ─── Expose state properties ──────────────────────────────────────────────── -let ChatControllerV2Comp = withExposingConfigs(ChatControllerV2WithProps, [ - new NameConfig( - "ready", - "Whether the signal server is connected and ready", - ), +let ChatControllerSignal = withExposingConfigs(ChatControllerSignalWithProps, [ + new NameConfig("ready", "Whether the signal server is connected and ready"), new NameConfig("error", "Error message if connection failed"), new NameConfig( "connectionStatus", @@ -417,7 +440,7 @@ let ChatControllerV2Comp = withExposingConfigs(ChatControllerV2WithProps, [ // ─── Expose methods ───────────────────────────────────────────────────────── -ChatControllerV2Comp = withMethodExposing(ChatControllerV2Comp, [ +ChatControllerSignal = withMethodExposing(ChatControllerSignal, [ { method: { name: "broadcastNewMessage", @@ -497,4 +520,4 @@ ChatControllerV2Comp = withMethodExposing(ChatControllerV2Comp, [ }, ]); -export { ChatControllerV2Comp }; +export { ChatControllerSignal }; diff --git a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx index 6e6e5f19a..f8edadb51 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx @@ -38,7 +38,7 @@ import UrlParamsHookComp from "./UrlParamsHookComp"; import { UtilsComp } from "./utilsComp"; import { ScreenInfoHookComp } from "./screenInfoComp"; import { ChatControllerComp } from "../comps/chatBoxComponent/chatControllerComp"; -import { ChatControllerV2Comp } from "./chatControllerV2Comp"; +import { ChatControllerSignal } from "./chatControllerV2Comp"; window._ = _; window.dayjs = dayjs; @@ -121,7 +121,7 @@ const HookMap: HookCompMapRawType = { drawer: DrawerComp, theme: ThemeComp, chatController: ChatControllerComp, - chatControllerV2: ChatControllerV2Comp, + chatControllerSignal: ChatControllerSignal, }; export const HookTmpComp = withTypeAndChildren(HookMap, "title", { diff --git a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx index ee63a7f6a..537dc096d 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx @@ -20,7 +20,7 @@ const AllHookComp = [ "theme", "meeting", "chatController", - "chatControllerV2" + "chatControllerSignal" ] as const; export type HookCompType = (typeof AllHookComp)[number]; @@ -55,7 +55,7 @@ const HookCompConfig: Record< category: "ui", singleton: false, }, - chatControllerV2: { + chatControllerSignal: { category: "ui", singleton: false, }, diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index 5e29a64f5..b391eb3ab 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -196,7 +196,7 @@ import { ContainerComp as FloatTextContainerComp } from "./comps/containerComp/t import { ChatComp } from "./comps/chatComp"; import { ChatBoxComp } from "./comps/chatBoxComponent"; import { ChatControllerComp } from "./comps/chatBoxComponent/chatControllerComp"; -import { ChatControllerV2Comp } from "./hooks/chatControllerV2Comp"; +import { ChatControllerSignal } from "./hooks/chatControllerV2Comp"; import { ChatBoxV2Comp } from "./comps/chatBoxComponentv2"; type Registry = { @@ -975,14 +975,14 @@ export var uiCompMap: Registry = { isContainer: true, }, - chatControllerV2: { + chatControllerSignal: { name: "Chat Signal Controller", enName: "Chat Signal Controller", description: "Signal server for real-time chat — broadcasts message activity, typing indicators, and online presence via Pluv/Yjs. Pair with Chat Box V2 and your own data queries.", categories: ["collaboration"], icon: CommentCompIcon, keywords: "chatbox,chat,controller,signal,realtime,presence,typing,pluv,yjs", - comp: ChatControllerV2Comp, + comp: ChatControllerSignal, }, chatBoxV: { diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index 35f80bbb8..97de827b8 100644 --- a/client/packages/lowcoder/src/comps/uiCompRegistry.ts +++ b/client/packages/lowcoder/src/comps/uiCompRegistry.ts @@ -145,7 +145,7 @@ export type UICompType = | "chat" //Added By Kamal Qureshi | "chatBox" //Added By Kamal Qureshi | "chatController" - | "chatControllerV2" + | "chatControllerSignal" | "chatBoxV" | "autocomplete" //Added By Mousheng | "colorPicker" //Added By Mousheng diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index 97a29ad78..bfbdb7b9e 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -309,6 +309,6 @@ export const CompStateIcon: { chat: , chatBox: , chatController: , - chatControllerV2: , + chatControllerSignal: , chatBoxV: , } as const; From 6ae73859eed8c68eae57e8ed9b23c2467aa3a0ef Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 17 Mar 2026 01:36:27 +0500 Subject: [PATCH 31/34] fix TS errors + chatbox context --- .../chatBoxComponentv2/ChatBoxContext.tsx | 72 +++++ .../comps/chatBoxComponentv2/chatBoxComp.tsx | 128 +++++---- .../components/ChatBoxView.tsx | 263 +++--------------- .../components/RoomPanel.tsx | 93 +++---- .../chatBoxComponentv2/store/ChatStore.ts | 2 - 5 files changed, 227 insertions(+), 331 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx delete mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx new file mode 100644 index 000000000..c2eaa2327 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx @@ -0,0 +1,72 @@ +import { createContext, useContext } from "react"; +import type { ChatRoom, PendingRoomInvite } from "./store"; + +type ChatEventName = + | "messageSent" + | "startTyping" + | "stopTyping" + | "roomSwitch" + | "roomJoin" + | "roomLeave" + | "roomCreate" + | "inviteSend" + | "inviteAccept" + | "inviteDecline"; + +interface ExposedState { + value: string; + onChange: (v: string) => void; +} + +export interface ChatBoxContextValue { + // Data + messages: any[]; + rooms: ChatRoom[]; + currentRoomId: string; + currentRoom: ChatRoom | null; + currentUserId: string; + currentUserName: string; + typingUsers: any[]; + pendingInvites: PendingRoomInvite[]; + + // Exposed state + chatTitle: ExposedState; + messageText: ExposedState; + lastSentMessageText: ExposedState; + + // UI config + showHeader: boolean; + showRoomsPanel: boolean; + roomsPanelWidth: string; + allowRoomCreation: boolean; + allowRoomSearch: boolean; + style: any; + animationStyle: any; + + // Events + onEvent: (event: ChatEventName) => any; + + // Room actions + onRoomSwitch: (roomId: string) => void; + onRoomJoin: (roomId: string) => void; + onRoomLeave: (roomId: string) => void; + onRoomCreate: ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ) => void; + onInviteSend: (toUserId: string) => void; + onInviteAccept: (inviteId: string) => void; + onInviteDecline: (inviteId: string) => void; +} + +export const ChatBoxContext = createContext(null); + +export function useChatBox(): ChatBoxContextValue { + const ctx = useContext(ChatBoxContext); + if (!ctx) { + throw new Error("useChatBox must be used within a ChatBoxProvider"); + } + return ctx; +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index ae3576a5f..d1e8d4fef 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -20,6 +20,8 @@ import { hiddenPropertyView } from "comps/utils/propertyUtils"; import { EditorContext } from "comps/editorState"; import { ChatBoxView } from "./components/ChatBoxView"; +import { ChatBoxContext } from "./ChatBoxContext"; +import type { ChatRoom, PendingRoomInvite } from "./store"; // ─── Events ────────────────────────────────────────────────────────────────── @@ -220,60 +222,80 @@ ChatBoxPropertyView.displayName = "ChatBoxV2PropertyView"; let ChatBoxV2Tmp = (function () { return new UICompBuilder(childrenMap, (props) => { + const messages = Array.isArray(props.messages) ? props.messages : []; + const rooms = (Array.isArray(props.rooms) ? props.rooms : []) as unknown as ChatRoom[]; + const typingUsers = Array.isArray(props.typingUsers) ? props.typingUsers : []; + const pendingInvites = (Array.isArray(props.pendingInvites) + ? props.pendingInvites + : []) as unknown as PendingRoomInvite[]; + const currentRoom = rooms.find((r) => r.id === props.currentRoomId) ?? null; + + const contextValue = { + messages, + rooms, + currentRoomId: props.currentRoomId, + currentRoom, + currentUserId: props.currentUserId, + currentUserName: props.currentUserName, + typingUsers, + pendingInvites, + + chatTitle: props.chatTitle, + messageText: props.messageText, + lastSentMessageText: props.lastSentMessageText, + + showHeader: props.showHeader, + showRoomsPanel: props.showRoomsPanel, + roomsPanelWidth: props.roomsPanelWidth, + allowRoomCreation: props.allowRoomCreation, + allowRoomSearch: props.allowRoomSearch, + style: props.style, + animationStyle: props.animationStyle, + + onEvent: props.onEvent, + + onRoomSwitch: (roomId: string) => { + props.pendingRoomId.onChange(roomId); + props.onEvent("roomSwitch"); + }, + onRoomJoin: (roomId: string) => { + props.pendingRoomId.onChange(roomId); + props.onEvent("roomJoin"); + }, + onRoomLeave: (roomId: string) => { + props.pendingRoomId.onChange(roomId); + props.onEvent("roomLeave"); + }, + onRoomCreate: ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ) => { + props.newRoomName.onChange(name); + props.newRoomType.onChange(type); + props.newRoomDescription.onChange(description || ""); + props.newRoomLlmQuery.onChange(llmQueryName || ""); + props.onEvent("roomCreate"); + }, + onInviteSend: (toUserId: string) => { + props.inviteTargetUserId.onChange(toUserId); + props.onEvent("inviteSend"); + }, + onInviteAccept: (inviteId: string) => { + props.pendingInviteId.onChange(inviteId); + props.onEvent("inviteAccept"); + }, + onInviteDecline: (inviteId: string) => { + props.pendingInviteId.onChange(inviteId); + props.onEvent("inviteDecline"); + }, + }; + return ( - { - props.pendingRoomId.onChange(roomId); - props.onEvent("roomSwitch"); - }} - onRoomJoin={(roomId) => { - props.pendingRoomId.onChange(roomId); - props.onEvent("roomJoin"); - }} - onRoomLeave={(roomId) => { - props.pendingRoomId.onChange(roomId); - props.onEvent("roomLeave"); - }} - onRoomCreate={(name, type, description, llmQueryName) => { - props.newRoomName.onChange(name); - props.newRoomType.onChange(type); - props.newRoomDescription.onChange(description || ""); - props.newRoomLlmQuery.onChange(llmQueryName || ""); - props.onEvent("roomCreate"); - }} - onInviteSend={(toUserId) => { - props.inviteTargetUserId.onChange(toUserId); - props.onEvent("inviteSend"); - }} - onInviteAccept={(inviteId) => { - props.pendingInviteId.onChange(inviteId); - props.onEvent("inviteAccept"); - }} - onInviteDecline={(inviteId) => { - props.pendingInviteId.onChange(inviteId); - props.onEvent("inviteDecline"); - }} - /> + + + ); }) .setPropertyViewFn((children) => ( diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx index 3b8a35130..cf5601496 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useState } from "react"; import { Wrapper, ChatPanelContainer, @@ -9,220 +9,26 @@ import { InputBar } from "./InputBar"; import { RoomPanel } from "./RoomPanel"; import { CreateRoomModal } from "./CreateRoomModal"; import { InviteUserModal } from "./InviteUserModal"; -import type { ChatRoom, PendingRoomInvite } from "../store"; - -export interface ChatBoxViewProps { - chatTitle: { value: string; onChange: (v: string) => void }; - showHeader: boolean; - messages: any; - currentUserId: string; - currentUserName: string; - typingUsers: any; - lastSentMessageText: { value: string; onChange: (v: string) => void }; - messageText: { value: string; onChange: (v: string) => void }; - style: any; - animationStyle: any; - onEvent: ( - event: - | "messageSent" - | "startTyping" - | "stopTyping" - | "roomSwitch" - | "roomJoin" - | "roomLeave" - | "roomCreate" - | "inviteSend" - | "inviteAccept" - | "inviteDecline", - ) => any; - - // Rooms panel - rooms: any; - currentRoomId: string; - pendingInvites: any; - showRoomsPanel: boolean; - roomsPanelWidth: string; - allowRoomCreation: boolean; - allowRoomSearch: boolean; - - // Interaction callbacks - onRoomSwitch: (roomId: string) => void; - onRoomJoin: (roomId: string) => void; - onRoomLeave: (roomId: string) => void; - onRoomCreate: ( - name: string, - type: "public" | "private" | "llm", - description?: string, - llmQueryName?: string, - ) => void; - onInviteSend: (toUserId: string) => void; - onInviteAccept: (inviteId: string) => void; - onInviteDecline: (inviteId: string) => void; -} - -export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { - const { - chatTitle, - showHeader, - messages, - currentUserId, - typingUsers, - lastSentMessageText, - messageText, - style, - animationStyle, - onEvent, - rooms, - currentRoomId, - pendingInvites, - showRoomsPanel, - roomsPanelWidth, - allowRoomCreation, - allowRoomSearch, - onRoomSwitch, - onRoomJoin, - onRoomLeave, - onRoomCreate, - onInviteSend, - onInviteAccept, - onInviteDecline, - } = props; +import { useChatBox } from "../ChatBoxContext"; +import type { ChatRoom } from "../store"; +export const ChatBoxView = React.memo(() => { + const ctx = useChatBox(); const [createModalOpen, setCreateModalOpen] = useState(false); const [inviteModalOpen, setInviteModalOpen] = useState(false); - const normalizedMessages = Array.isArray(messages) ? messages : []; - const normalizedTypingUsers = Array.isArray(typingUsers) ? typingUsers : []; - const normalizedRooms = Array.isArray(rooms) ? (rooms as ChatRoom[]) : []; - const normalizedInvites = Array.isArray(pendingInvites) - ? (pendingInvites as PendingRoomInvite[]) - : []; - - // ── Current room object ──────────────────────────────────────────── - const currentRoom = useMemo( - () => normalizedRooms.find((r) => r.id === currentRoomId) ?? null, - [normalizedRooms, currentRoomId], - ); - - // ── Message handlers ─────────────────────────────────────────────── - const handleSend = useCallback( - (text: string) => { - lastSentMessageText.onChange(text); - onEvent("messageSent"); - }, - [lastSentMessageText, onEvent], - ); - - const handleStartTyping = useCallback(() => { - onEvent("startTyping"); - }, [onEvent]); - - const handleStopTyping = useCallback(() => { - onEvent("stopTyping"); - }, [onEvent]); - - const handleDraftChange = useCallback( - (text: string) => { - messageText.onChange(text); - }, - [messageText], - ); - - // ── Room panel handlers ──────────────────────────────────────────── - const handleSwitchRoom = useCallback( - (roomId: string) => { - onRoomSwitch(roomId); - }, - [onRoomSwitch], - ); - - const handleJoinRoom = useCallback( - (roomId: string) => { - onRoomJoin(roomId); - }, - [onRoomJoin], - ); - - const handleLeaveRoom = useCallback( - (roomId: string) => { - onRoomLeave(roomId); - }, - [onRoomLeave], - ); - - // Client-side search: public rooms matching query - const handleSearchRooms = useCallback( - async (query: string): Promise => { - if (!query.trim()) return []; - const q = query.toLowerCase(); - return normalizedRooms.filter( - (r) => r.type === "public" && r.name.toLowerCase().includes(q), - ); - }, - [normalizedRooms], - ); - - // ── Create room modal ────────────────────────────────────────────── - const handleCreateRoom = useCallback( - async ( - name: string, - type: "public" | "private" | "llm", - description?: string, - llmQueryName?: string, - ): Promise => { - onRoomCreate(name, type, description, llmQueryName); - // Return a placeholder so the modal closes immediately; - // the real room will appear via the controller's Pluv sync. - const placeholder: ChatRoom = { - id: "__pending__", - name, - type, - description: description || null, - members: [currentUserId], - createdBy: currentUserId, - createdAt: Date.now(), - llmQueryName: llmQueryName || null, - }; - return placeholder; - }, - [onRoomCreate, currentUserId], - ); - - // ── Invite modal handlers ────────────────────────────────────────── - const handleSendInvite = useCallback( - async (toUserId: string): Promise => { - onInviteSend(toUserId); - return true; - }, - [onInviteSend], - ); - - // ── Derive chat header label ─────────────────────────────────────── - const headerTitle = currentRoom - ? currentRoom.name - : chatTitle.value; + const headerTitle = ctx.currentRoom + ? ctx.currentRoom.name + : ctx.chatTitle.value; return ( - + {/* ── Rooms sidebar ───────────────────────────────────────── */} - {showRoomsPanel && ( + {ctx.showRoomsPanel && ( onInviteAccept(id)} - onDeclineInvite={(id) => onInviteDecline(id)} onCreateModalOpen={() => setCreateModalOpen(true)} onInviteModalOpen={ - currentRoom?.type === "private" + ctx.currentRoom?.type === "private" ? () => setInviteModalOpen(true) : undefined } @@ -231,30 +37,33 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { {/* ── Chat area ───────────────────────────────────────────── */} - {showHeader && ( + {ctx.showHeader && (
{headerTitle}
- {currentRoom?.description && ( + {ctx.currentRoom?.description && (
- {currentRoom.description} + {ctx.currentRoom.description}
)}
)} { + ctx.lastSentMessageText.onChange(text); + ctx.onEvent("messageSent"); + }} + onStartTyping={() => ctx.onEvent("startTyping")} + onStopTyping={() => ctx.onEvent("stopTyping")} + onDraftChange={(text) => ctx.messageText.onChange(text)} />
@@ -262,15 +71,31 @@ export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { setCreateModalOpen(false)} - onCreateRoom={handleCreateRoom} - onRoomCreatedEvent={() => {/* event already fired inside handleCreateRoom */}} + onCreateRoom={async (name, type, description, llmQueryName) => { + ctx.onRoomCreate(name, type, description, llmQueryName); + const placeholder: ChatRoom = { + id: "__pending__", + name, + type, + description: description || null, + members: [ctx.currentUserId], + createdBy: ctx.currentUserId, + createdAt: Date.now(), + llmQueryName: llmQueryName || null, + }; + return placeholder; + }} + onRoomCreatedEvent={() => {}} /> setInviteModalOpen(false)} - currentRoom={currentRoom} - onSendInvite={handleSendInvite} + currentRoom={ctx.currentRoom} + onSendInvite={async (toUserId) => { + ctx.onInviteSend(toUserId); + return true; + }} />
); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx index 6ae07bb8d..714da4f30 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useState } from "react"; import { Button, Input, Tooltip, Popconfirm } from "antd"; import { PlusOutlined, @@ -10,7 +10,7 @@ import { MailOutlined, UserAddOutlined, } from "@ant-design/icons"; -import type { ChatRoom, PendingRoomInvite } from "../store"; +import type { ChatRoom } from "../store"; import { RoomPanelContainer, RoomPanelHeader, @@ -19,80 +19,60 @@ import { SearchResultBadge, LlmRoomBadge, } from "../styles"; +import { useChatBox } from "../ChatBoxContext"; export interface RoomPanelProps { - width: string; - rooms: ChatRoom[]; - currentRoomId: string | undefined; - ready: boolean; - allowRoomCreation: boolean; - allowRoomSearch: boolean; - onSwitchRoom: (roomId: string) => void; - onJoinRoom: (roomId: string) => void; - onLeaveRoom: (roomId: string) => void; - onSearchRooms: (query: string) => Promise; - pendingInvites: PendingRoomInvite[]; - onAcceptInvite: (inviteId: string) => void; - onDeclineInvite: (inviteId: string) => void; onCreateModalOpen: () => void; onInviteModalOpen?: () => void; } export const RoomPanel = React.memo((props: RoomPanelProps) => { + const { onCreateModalOpen, onInviteModalOpen } = props; const { - width, rooms, currentRoomId, - ready, allowRoomCreation, allowRoomSearch, - onSwitchRoom, - onJoinRoom, - onLeaveRoom, - onSearchRooms, + roomsPanelWidth, pendingInvites, - onAcceptInvite, - onDeclineInvite, - onCreateModalOpen, - onInviteModalOpen, - } = props; + onRoomSwitch, + onRoomJoin, + onRoomLeave, + onInviteAccept, + onInviteDecline, + } = useChatBox(); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearchMode, setIsSearchMode] = useState(false); - const handleSearch = useCallback( - async (q: string) => { - setSearchQuery(q); - if (!q.trim()) { - setIsSearchMode(false); - setSearchResults([]); - return; - } - setIsSearchMode(true); - const results = await onSearchRooms(q); - setSearchResults(results); - }, - [onSearchRooms], - ); + const handleSearch = (q: string) => { + setSearchQuery(q); + if (!q.trim()) { + setIsSearchMode(false); + setSearchResults([]); + return; + } + setIsSearchMode(true); + const lower = q.toLowerCase(); + setSearchResults( + rooms.filter((r) => r.type === "public" && r.name.toLowerCase().includes(lower)), + ); + }; - const clearSearch = useCallback(() => { + const clearSearch = () => { setSearchQuery(""); setIsSearchMode(false); setSearchResults([]); - }, []); + }; - const handleJoinAndClear = useCallback( - (roomId: string) => { - onJoinRoom(roomId); - clearSearch(); - }, - [onJoinRoom, clearSearch], - ); + const handleJoinAndClear = (roomId: string) => { + onRoomJoin(roomId); + clearSearch(); + }; const roomListItems = isSearchMode ? searchResults : rooms; - // Group rooms by type for display const publicRooms = roomListItems.filter((r) => r.type === "public"); const privateRooms = roomListItems.filter((r) => r.type === "private"); const llmRooms = roomListItems.filter((r) => r.type === "llm"); @@ -109,7 +89,7 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { if (isSearch) { handleJoinAndClear(room.id); } else if (!isActive) { - onSwitchRoom(room.id); + onRoomSwitch(room.id); } }} title={isSearch ? `Join "${room.name}"` : room.name} @@ -154,7 +134,7 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { title={`Leave "${room.name}"?`} onConfirm={(e) => { e?.stopPropagation(); - onLeaveRoom(room.id); + onRoomLeave(room.id); }} onCancel={(e) => e?.stopPropagation()} okText="Leave" @@ -172,7 +152,7 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { }; return ( - + Rooms
@@ -275,11 +255,11 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { -
@@ -289,7 +269,7 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { )} - {roomListItems.length === 0 && !isSearchMode && ready && ( + {roomListItems.length === 0 && !isSearchMode && (
{
)} - {/* Render grouped when not in search mode */} {isSearchMode ? roomListItems.map(renderRoomItem) : ( diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts deleted file mode 100644 index f3a8fa1ea..000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Placeholder — all real-time logic is now in ChatControllerSignal. -// Data storage is handled by the user's own Data Sources & Queries. From d5b6b6250fbee7c64b772628b2c264ae8ac10568 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 17 Mar 2026 03:54:35 +0500 Subject: [PATCH 32/34] add online presence rooms --- .../chatBoxComponentv2/ChatBoxContext.tsx | 3 +- .../comps/chatBoxComponentv2/chatBoxComp.tsx | 8 ++ .../components/ChatBoxView.tsx | 32 ++++++-- .../components/RoomPanel.tsx | 63 ++++++++++++++- .../comps/comps/chatBoxComponentv2/styles.ts | 80 +++++++++++++++++++ 5 files changed, 176 insertions(+), 10 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx index c2eaa2327..bb1aceae8 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import type { ChatRoom, PendingRoomInvite } from "./store"; +import type { ChatRoom, OnlineUser, PendingRoomInvite } from "./store"; type ChatEventName = | "messageSent" @@ -27,6 +27,7 @@ export interface ChatBoxContextValue { currentUserId: string; currentUserName: string; typingUsers: any[]; + onlineUsers: OnlineUser[]; pendingInvites: PendingRoomInvite[]; // Exposed state diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index d1e8d4fef..1a8b42154 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -105,6 +105,7 @@ const childrenMap = { rooms: jsonArrayControl([]), currentRoomId: withDefault(StringControl, ""), pendingInvites: jsonArrayControl([]), + onlineUsers: jsonArrayControl([]), showRoomsPanel: withDefault(BoolControl, true), roomsPanelWidth: withDefault(StringControl, "240px"), allowRoomCreation: withDefault(BoolControl, true), @@ -186,6 +187,11 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => { tooltip: "Array of users currently typing. Bind to {{ chatController1.typingUsers }}", })} + {children.onlineUsers.propertyView({ + label: "Online Users", + tooltip: + "Array of online users with presence. Bind to {{ chatController1.onlineUsers }}. Shape: [{ userId, userName, currentRoomId }]", + })}
@@ -225,6 +231,7 @@ let ChatBoxV2Tmp = (function () { const messages = Array.isArray(props.messages) ? props.messages : []; const rooms = (Array.isArray(props.rooms) ? props.rooms : []) as unknown as ChatRoom[]; const typingUsers = Array.isArray(props.typingUsers) ? props.typingUsers : []; + const onlineUsers = Array.isArray(props.onlineUsers) ? props.onlineUsers : []; const pendingInvites = (Array.isArray(props.pendingInvites) ? props.pendingInvites : []) as unknown as PendingRoomInvite[]; @@ -238,6 +245,7 @@ let ChatBoxV2Tmp = (function () { currentUserId: props.currentUserId, currentUserName: props.currentUserName, typingUsers, + onlineUsers: onlineUsers as any, pendingInvites, chatTitle: props.chatTitle, diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx index cf5601496..10689e9a1 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -1,8 +1,10 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Wrapper, ChatPanelContainer, ChatHeaderBar, + OnlineCountBadge, + OnlineCountDot, } from "../styles"; import { MessageList } from "./MessageList"; import { InputBar } from "./InputBar"; @@ -21,6 +23,14 @@ export const ChatBoxView = React.memo(() => { ? ctx.currentRoom.name : ctx.chatTitle.value; + // Count users online in the current room (peers only, not counting self) + const roomOnlineCount = useMemo(() => { + if (!ctx.currentRoomId) return 0; + return ctx.onlineUsers.filter( + (u) => u.currentRoomId === ctx.currentRoomId && u.userId !== ctx.currentUserId, + ).length + 1; // +1 for self + }, [ctx.onlineUsers, ctx.currentRoomId, ctx.currentUserId]); + return ( {/* ── Rooms sidebar ───────────────────────────────────────── */} @@ -39,13 +49,21 @@ export const ChatBoxView = React.memo(() => { {ctx.showHeader && ( -
- {headerTitle} -
- {ctx.currentRoom?.description && ( -
- {ctx.currentRoom.description} +
+
+ {headerTitle}
+ {ctx.currentRoom?.description && ( +
+ {ctx.currentRoom.description} +
+ )} +
+ {ctx.currentRoomId && roomOnlineCount > 0 && ( + + + {roomOnlineCount} online + )} )} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx index 714da4f30..573bab1c9 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Button, Input, Tooltip, Popconfirm } from "antd"; import { PlusOutlined, @@ -9,8 +9,9 @@ import { RobotOutlined, MailOutlined, UserAddOutlined, + TeamOutlined, } from "@ant-design/icons"; -import type { ChatRoom } from "../store"; +import type { ChatRoom, OnlineUser } from "../store"; import { RoomPanelContainer, RoomPanelHeader, @@ -18,6 +19,12 @@ import { RoomItemStyled, SearchResultBadge, LlmRoomBadge, + OnlinePresenceSection, + OnlinePresenceLabel, + OnlineUserItem, + OnlineAvatar, + OnlineDot, + OnlineUserName, } from "../styles"; import { useChatBox } from "../ChatBoxContext"; @@ -31,10 +38,13 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { const { rooms, currentRoomId, + currentUserId, + currentUserName, allowRoomCreation, allowRoomSearch, roomsPanelWidth, pendingInvites, + onlineUsers, onRoomSwitch, onRoomJoin, onRoomLeave, @@ -42,6 +52,19 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { onInviteDecline, } = useChatBox(); + // Users in the current room (from Pluv presence), plus self + const roomOnlineUsers = useMemo(() => { + const peers = onlineUsers.filter( + (u) => u.currentRoomId === currentRoomId && u.userId !== currentUserId, + ); + const self: OnlineUser = { + userId: currentUserId, + userName: currentUserName, + currentRoomId, + }; + return currentRoomId ? [self, ...peers] : peers; + }, [onlineUsers, currentRoomId, currentUserId, currentUserName]); + const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearchMode, setIsSearchMode] = useState(false); @@ -308,12 +331,48 @@ export const RoomPanel = React.memo((props: RoomPanelProps) => { )} + + {/* ── Online Presence ─────────────────────────────────────── */} + {currentRoomId && roomOnlineUsers.length > 0 && ( + + + + Online — {roomOnlineUsers.length} + + {roomOnlineUsers.map((user) => ( + + + {(user.userName || user.userId).slice(0, 1).toUpperCase()} + + + + {user.userId === currentUserId ? `${user.userName} (You)` : user.userName} + + + ))} + + )} ); }); RoomPanel.displayName = "RoomPanel"; +// ── Avatar color helper ─────────────────────────────────────────────────────── + +const AVATAR_PALETTE = [ + "#1890ff", "#52c41a", "#fa8c16", "#722ed1", + "#eb2f96", "#13c2c2", "#faad14", "#f5222d", +]; + +function avatarColor(userId: string): string { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = userId.charCodeAt(i) + ((hash << 5) - hash); + } + return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]; +} + // ── Section label ───────────────────────────────────────────────────────────── const RoomSectionLabel = React.memo(({ label }: { label: string }) => ( diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts index d733a49fc..9048e14aa 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts @@ -346,3 +346,83 @@ export const LlmRoomBadge = styled.span` padding: 1px 5px; flex-shrink: 0; `; + +// ── Online Presence styles ────────────────────────────────────────────────── + +export const OnlinePresenceSection = styled.div` + border-top: 1px solid #eee; + padding: 8px; + flex-shrink: 0; +`; + +export const OnlinePresenceLabel = styled.div` + font-size: 10px; + font-weight: 600; + color: #aaa; + letter-spacing: 0.6px; + text-transform: uppercase; + padding: 4px 2px 6px; + display: flex; + align-items: center; + gap: 6px; +`; + +export const OnlineUserItem = styled.div` + display: flex; + align-items: center; + gap: 7px; + padding: 4px 2px; + font-size: 12px; + color: #444; + overflow: hidden; +`; + +export const OnlineAvatar = styled.div<{ $color: string }>` + width: 22px; + height: 22px; + border-radius: 50%; + background: ${(p) => p.$color}; + color: #fff; + font-size: 10px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; +`; + +export const OnlineDot = styled.span` + position: absolute; + bottom: -1px; + right: -1px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #52c41a; + border: 1.5px solid #fafbfc; +`; + +export const OnlineUserName = styled.span` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const OnlineCountBadge = styled.span` + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: #52c41a; + font-weight: 500; +`; + +export const OnlineCountDot = styled.span` + width: 7px; + height: 7px; + border-radius: 50%; + background: #52c41a; + display: inline-block; +`; From ab37868d24fd7ca63dd18853f1118d914b1ad714 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Wed, 18 Mar 2026 02:48:01 +0500 Subject: [PATCH 33/34] fix message styles, autoscroll and pluv server --- .../chatBoxComponentv2/ChatBoxContext.tsx | 1 + .../comps/chatBoxComponentv2/chatBoxComp.tsx | 9 ++ .../components/ChatBoxView.tsx | 1 + .../components/MessageList.tsx | 38 +++++-- .../comps/chatBoxComponentv2/store/index.ts | 2 +- .../chatBoxComponentv2/store/pluvClient.ts | 55 +++++---- .../comps/chatBoxComponentv2/store/types.ts | 6 + .../comps/comps/chatBoxComponentv2/styles.ts | 11 +- .../src/comps/hooks/chatControllerV2Comp.tsx | 104 +++++++++++++----- 9 files changed, 162 insertions(+), 65 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx index bb1aceae8..84c7e58ec 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx @@ -29,6 +29,7 @@ export interface ChatBoxContextValue { typingUsers: any[]; onlineUsers: OnlineUser[]; pendingInvites: PendingRoomInvite[]; + isAiThinking: boolean; // Exposed state chatTitle: ExposedState; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx index 1a8b42154..0c6558e59 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -98,6 +98,7 @@ const childrenMap = { currentUserId: withDefault(StringControl, "user_1"), currentUserName: withDefault(StringControl, "User"), typingUsers: jsonArrayControl([]), + isAiThinking: withDefault(BoolControl, false), lastSentMessageText: stringExposingStateControl("lastSentMessageText", ""), messageText: stringExposingStateControl("messageText", ""), @@ -187,6 +188,11 @@ const ChatBoxPropertyView = React.memo((props: { children: any }) => { tooltip: "Array of users currently typing. Bind to {{ chatController1.typingUsers }}", })} + {children.isAiThinking.propertyView({ + label: "AI Is Thinking", + tooltip: + "Show the AI thinking animation to all users in this room. Bind to {{ chatController1.aiThinkingRooms[chatBox1.currentRoomId] }}", + })} {children.onlineUsers.propertyView({ label: "Online Users", tooltip: @@ -232,6 +238,7 @@ let ChatBoxV2Tmp = (function () { const rooms = (Array.isArray(props.rooms) ? props.rooms : []) as unknown as ChatRoom[]; const typingUsers = Array.isArray(props.typingUsers) ? props.typingUsers : []; const onlineUsers = Array.isArray(props.onlineUsers) ? props.onlineUsers : []; + const isAiThinking = Boolean(props.isAiThinking); const pendingInvites = (Array.isArray(props.pendingInvites) ? props.pendingInvites : []) as unknown as PendingRoomInvite[]; @@ -246,6 +253,7 @@ let ChatBoxV2Tmp = (function () { currentUserName: props.currentUserName, typingUsers, onlineUsers: onlineUsers as any, + isAiThinking, pendingInvites, chatTitle: props.chatTitle, @@ -325,6 +333,7 @@ export const ChatBoxV2Comp = withExposingConfigs(ChatBoxV2Tmp, [ "Text of the last message sent by the user — use in your save query", ), new NameConfig("messageText", "Current text in the message input"), + new NameConfig("currentRoomId", "Currently active room ID — for AI thinking or room-scoped queries"), new NameConfig( "pendingRoomId", "Room ID the user wants to switch to, join, or leave — read in roomSwitch/roomJoin/roomLeave events", diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx index 10689e9a1..7e35ae757 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -72,6 +72,7 @@ export const ChatBoxView = React.memo(() => { messages={ctx.messages} typingUsers={ctx.typingUsers} currentUserId={ctx.currentUserId} + isAiThinking={ctx.isAiThinking} /> { - const { messages, typingUsers, currentUserId } = props; - const bottomRef = useRef(null); + const { messages, typingUsers, currentUserId, isAiThinking = false } = props; + const containerRef = useRef(null); useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages.length]); + if (containerRef.current) { + containerRef.current.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: "smooth", + }); + } + }, [messages.length, isAiThinking]); return ( - + {messages.length === 0 ? (
💬
@@ -151,7 +159,7 @@ export const MessageList = React.memo((props: MessageListProps) => { } return ( -
+ {authorName} {text} {timestamp > 0 && ( @@ -162,11 +170,26 @@ export const MessageList = React.memo((props: MessageListProps) => { })} )} -
+ ); }) )} + {/* AI thinking animation — shown to all users when the LLM is generating */} + {isAiThinking && ( + + + + AI is thinking… + + + + + + + + )} + {typingUsers.length > 0 && ( @@ -182,7 +205,6 @@ export const MessageList = React.memo((props: MessageListProps) => { )} -
); }); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts index c4fecaaf9..c8d29737e 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts @@ -1,4 +1,5 @@ export type { + AiThinkingState, ChatMessage, ChatRoom, PendingRoomInvite, @@ -19,5 +20,4 @@ export { useRoom, useConnection, useDoc, - pluvConfig, } from "./pluvClient"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts index 73a3e7d9e..241eff3d1 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts @@ -3,42 +3,41 @@ import { yjs } from "@pluv/crdt-yjs"; import { createBundle } from "@pluv/react"; import { z } from "zod"; -/** - * Module-level config updated by ChatControllerV2 before connecting. - * Allows dynamic auth without recreating the client. - */ -export const pluvConfig = { - userId: "", - userName: "", - authUrl: "/api/auth/pluv", - publicKey: "", -}; +// Resolve the pluv.io publishable key from the environment. +// This is set at build time via VITE_PLUV_PUBLIC_KEY, or injected at runtime +// via globalThis.__PLUV_PUBLIC_KEY__ (e.g. from a server-rendered template). +const PLUV_PUBLIC_KEY: string = + (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PLUV_PUBLIC_KEY) || + (typeof globalThis !== "undefined" && (globalThis as any).__PLUV_PUBLIC_KEY__) || + ""; -function resolvePluvPublicKey(): string { - return ( - pluvConfig.publicKey || - (typeof globalThis !== "undefined" - ? (globalThis as any).__PLUV_PUBLIC_KEY__ - : "") || - (typeof import.meta !== "undefined" - ? (import.meta as any).env?.VITE_PLUV_PUBLIC_KEY - : "") || - "" - ); -} +// Auth server URL. Defaults to a relative path so the Vite dev proxy and +// production reverse-proxy both work without extra configuration. +const PLUV_AUTH_URL: string = + (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PLUV_AUTH_URL) || + "/api/auth/pluv"; +// `metadata` is PLUV's built-in mechanism for passing per-connection data +// (like the current user) into the authEndpoint at the moment a room is +// entered. It is provided as a prop on , +// so there is no need for any global mutable config object. const client = createClient({ - authEndpoint: (({ room }: { room: string }) => { + metadata: z.object({ + userId: z.string(), + userName: z.string(), + }), + publicKey: PLUV_PUBLIC_KEY, + authEndpoint: ({ room, metadata }: { room: string; metadata: { userId: string; userName: string } }) => { const params = new URLSearchParams({ room, - userId: pluvConfig.userId, - userName: pluvConfig.userName, + userId: metadata.userId, + userName: metadata.userName, }); - return `${pluvConfig.authUrl}?${params}`; - }) as any, - publicKey: resolvePluvPublicKey as any, + return `${PLUV_AUTH_URL}?${params}`; + }, initialStorage: yjs.doc((t: any) => ({ messageActivity: t.map("messageActivity", []), + aiActivity: t.map("aiActivity", []), })), presence: z.object({ userId: z.string(), diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts index 2562d38d8..f2a2ef7be 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts @@ -50,6 +50,12 @@ export interface MessageBroadcast { counter: number; } +export interface AiThinkingState { + roomId: string; + isThinking: boolean; + timestamp: number; +} + export const LLM_BOT_AUTHOR_ID = "__llm_bot__"; export function uid(): string { diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts index 9048e14aa..5f5992de6 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts @@ -91,11 +91,16 @@ export const MessagesArea = styled.div` gap: 8px; `; -export const Bubble = styled.div<{ $own: boolean }>` +export const MessageWrapper = styled.div<{ $own: boolean }>` + display: flex; + flex-direction: column; + align-self: ${(p) => (p.$own ? "flex-end" : "flex-start")}; max-width: 70%; +`; + +export const Bubble = styled.div<{ $own: boolean }>` padding: 10px 14px; - border-radius: 16px; - align-self: ${(p) => (p.$own ? "flex-end" : "flex-start")}; + border-radius: ${(p) => (p.$own ? "16px 16px 4px 16px" : "16px 16px 16px 4px")}; background: ${(p) => (p.$own ? "#1890ff" : "#f0f0f0")}; color: ${(p) => (p.$own ? "#fff" : "#333")}; font-size: 14px; diff --git a/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx index d481d1cfe..022bcc485 100644 --- a/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx +++ b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx @@ -10,7 +10,6 @@ import { import { NameConfig, withExposingConfigs } from "../generators/withExposing"; import { withMethodExposing } from "../generators/withMethodExposing"; import { stringExposingStateControl } from "comps/controls/codeStateControl"; -import { StringControl } from "comps/controls/codeControl"; import { eventHandlerControl } from "comps/controls/eventHandlerControl"; import { JSONObject } from "../../util/jsonTypes"; import { @@ -19,10 +18,9 @@ import { useMyPresence, useOthers, useConnection, - pluvConfig, - uid, } from "../comps/chatBoxComponentv2/store"; import type { + AiThinkingState, MessageBroadcast, OnlineUser, TypingUser, @@ -66,6 +64,16 @@ const ChatControllerEvents = [ value: "error", description: "A connection error occurred", }, + { + label: "AI Thinking Started", + value: "aiThinkingStarted", + description: "The AI assistant started generating a response in a room", + }, + { + label: "AI Thinking Stopped", + value: "aiThinkingStopped", + description: "The AI assistant finished (or was cancelled) in a room", + }, ] as const; // ─── Children map ─────────────────────────────────────────────────────────── @@ -74,8 +82,6 @@ const childrenMap = { applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), userId: stringExposingStateControl("userId", "user_1"), userName: stringExposingStateControl("userName", "User"), - pluvPublicKey: withDefault(StringControl, ""), - pluvAuthUrl: withDefault(StringControl, "/api/auth/pluv"), onEvent: eventHandlerControl(ChatControllerEvents), @@ -86,6 +92,7 @@ const childrenMap = { typingUsers: stateComp([]), currentRoomId: stateComp(null), lastMessageNotification: stateComp(null), + aiThinkingRooms: stateComp({}), _signalActions: stateComp({}), }; @@ -97,6 +104,7 @@ interface SignalActions { startTyping: (roomId?: string) => void; stopTyping: () => void; switchRoom: (roomId: string) => void; + setAiThinking: (roomId: string, isThinking: boolean) => void; } // ─── Inner component that uses Pluv hooks inside PluvRoomProvider ──────────── @@ -113,6 +121,7 @@ const SignalController = React.memo( const [, setMyPresence] = useMyPresence(); const others = useOthers(); const [messageActivity, messageActivityYMap] = useStorage("messageActivity"); + const [aiActivity, aiActivityYMap] = useStorage("aiActivity"); const compRef = useRef(comp); compRef.current = comp; @@ -126,11 +135,13 @@ const SignalController = React.memo( onlineCount: number; lastBroadcastCounter: Record; initialized: boolean; + aiThinkingRooms: Record; }>({ ready: false, onlineCount: 0, lastBroadcastCounter: {}, initialized: false, + aiThinkingRooms: {}, }); // ── Connection state ────────────────────────────────────────────── @@ -224,6 +235,28 @@ const SignalController = React.memo( } }, [messageActivity, userId]); + // ── Watch AI activity (thinking state per room) ─────────────── + useEffect(() => { + if (!aiActivity) return; + const activityRecord = aiActivity as Record; + const nextThinking: Record = {}; + + for (const [roomId, state] of Object.entries(activityRecord)) { + nextThinking[roomId] = state.isThinking; + const prev = prevRef.current.aiThinkingRooms[roomId] ?? false; + if (state.isThinking && !prev) { + triggerEventRef.current("aiThinkingStarted"); + } else if (!state.isThinking && prev) { + triggerEventRef.current("aiThinkingStopped"); + } + } + + prevRef.current.aiThinkingRooms = nextThinking; + compRef.current.children.aiThinkingRooms.dispatchChangeValueAction( + nextThinking as unknown as JSONObject, + ); + }, [aiActivity]); + // ── Actions for method invocation ───────────────────────────────── const broadcastNewMessage = useCallback( @@ -234,7 +267,7 @@ const SignalController = React.memo( | undefined; const broadcast: MessageBroadcast = { roomId, - messageId: messageId || uid(), + messageId: messageId || crypto.randomUUID(), authorId: userId, authorName: userName, timestamp: Date.now(), @@ -281,18 +314,33 @@ const SignalController = React.memo( [setMyPresence, userId, userName], ); + const setAiThinking = useCallback( + (roomId: string, isThinking: boolean) => { + if (!aiActivityYMap) return; + const state: AiThinkingState = { + roomId, + isThinking, + timestamp: Date.now(), + }; + aiActivityYMap.set(roomId, state); + }, + [aiActivityYMap], + ); + // ── Proxy ref for stable callbacks ──────────────────────────────── const actionsRef = useRef({ broadcastNewMessage, startTyping, stopTyping, switchRoom, + setAiThinking, }); actionsRef.current = { broadcastNewMessage, startTyping, stopTyping, switchRoom, + setAiThinking, }; useEffect(() => { @@ -301,6 +349,7 @@ const SignalController = React.memo( startTyping: (...args) => actionsRef.current.startTyping(...args), stopTyping: () => actionsRef.current.stopTyping(), switchRoom: (...args) => actionsRef.current.switchRoom(...args), + setAiThinking: (...args) => actionsRef.current.setAiThinking(...args), }; compRef.current.children._signalActions.dispatchChangeValueAction( proxy as unknown as JSONObject, @@ -331,19 +380,13 @@ const ChatControllerSignalBase = withViewFn( const userId = comp.children.userId.getView().value; const userName = comp.children.userName.getView().value; const applicationId = comp.children.applicationId.getView().value; - const pluvPublicKey = comp.children.pluvPublicKey.getView(); - const pluvAuthUrl = comp.children.pluvAuthUrl.getView(); - - pluvConfig.userId = userId || "user_1"; - pluvConfig.userName = userName || "User"; - pluvConfig.authUrl = pluvAuthUrl || "/api/auth/pluv"; - pluvConfig.publicKey = pluvPublicKey || ""; const roomName = `signal_${applicationId || "lowcoder_app"}`; return ( ({ messageActivity: t.map("messageActivity", []), + aiActivity: t.map("aiActivity", []), })} onAuthorizationFail={(error: Error) => { console.error("[ChatControllerV2] Auth failed:", error); @@ -392,18 +436,6 @@ const ChatControllerSignalWithProps = withPropertyViewFn( tooltip: "Current user's display name", })}
-
- {comp.children.pluvPublicKey.propertyView({ - label: "Public Key", - tooltip: - "Pluv.io publishable key (pk_...). Can also be set via VITE_PLUV_PUBLIC_KEY env var.", - })} - {comp.children.pluvAuthUrl.propertyView({ - label: "Auth URL", - tooltip: - "Pluv auth endpoint URL for token exchange (e.g. /api/auth/pluv or http://localhost:3006/api/auth/pluv)", - })} -
{comp.children.onEvent.getPropertyView()}
@@ -436,6 +468,10 @@ let ChatControllerSignal = withExposingConfigs(ChatControllerSignalWithProps, [ new NameConfig("userId", "Current user ID"), new NameConfig("userName", "Current user name"), new NameConfig("applicationId", "Application scope ID"), + new NameConfig( + "aiThinkingRooms", + "Map of roomId → boolean indicating which rooms have an AI currently thinking. E.g. { 'room_123': true }", + ), ]); // ─── Expose methods ───────────────────────────────────────────────────────── @@ -502,6 +538,24 @@ ChatControllerSignal = withMethodExposing(ChatControllerSignal, [ } }, }, + { + method: { + name: "setAiThinking", + description: + "Broadcast to all room members that the AI assistant is thinking (or has finished). All users in the room will see the thinking indicator.", + params: [ + { name: "roomId", type: "string" }, + { name: "isThinking", type: "string" }, + ], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.setAiThinking) { + const isThinking = values?.[1] === true || values?.[1] === "true"; + actions.setAiThinking(values?.[0] as string, isThinking); + } + }, + }, { method: { name: "setUser", From c2daad6b4392324f5279b556c8207be7618c4515 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 19 Mar 2026 00:16:59 +0500 Subject: [PATCH 34/34] add shared state and fix date format --- .../comps/chatBoxComponentv2/READMEv2.md | 622 ++++++++++++++++++ .../UPDATED_MESSAGE_SENT_EXAMPLE.js | 43 ++ .../components/MessageList.tsx | 46 +- .../comps/chatBoxComponentv2/store/index.ts | 1 - .../chatBoxComponentv2/store/pluvClient.ts | 3 +- .../comps/chatBoxComponentv2/store/types.ts | 9 - .../src/comps/hooks/chatControllerV2Comp.tsx | 267 +++++--- .../lowcoder/src/util/dateTimeUtils.ts | 19 + 8 files changed, 890 insertions(+), 120 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/READMEv2.md create mode 100644 client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/UPDATED_MESSAGE_SENT_EXAMPLE.js diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/READMEv2.md b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/READMEv2.md new file mode 100644 index 000000000..871b45368 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/READMEv2.md @@ -0,0 +1,622 @@ +# ChatBox V2 + ChatController — Complete Guide + +## Architecture Overview + +The chat system uses two components and a server: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser (each user) │ +│ │ +│ ┌──────────────────┐ ┌───────────────────────────┐ │ +│ │ ChatController │◄─────►│ Pluv.io (WebSocket/YJS) │ │ +│ │ (hook component) │ │ CRDT auto-sync layer │ │ +│ │ │ └───────────────────────────┘ │ +│ │ Exposes: │ │ +│ │ • sharedState │ ┌───────────────────────────┐ │ +│ │ • roomData │ │ ChatBox │ │ +│ │ • onlineUsers │──────►│ (UI component) │ │ +│ │ • typingUsers │ │ Displays messages, rooms │ │ +│ │ • methods │ └───────────────────────────┘ │ +│ └──────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ MongoDB Queries │ ← You create these (save/load) │ +│ │ (data source) │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────┐ ┌──────────────────────┐ +│ pluv-server.js │ │ MongoDB Atlas │ +│ (auth + webhooks) │ │ (your database) │ +│ Port 3006 │ │ │ +└──────────────────────┘ └──────────────────────┘ +``` + +**ChatController** is a non-visual hook component. It manages: +- Real-time shared state (YJS CRDT via Pluv.io) — auto-syncs JSON across all users +- Presence — who's online, who's typing, what room they're in +- Room-scoped data — invisible JSON data per room/channel + +**ChatBox** is the visual chat UI. It receives data via property bindings and fires events on user interactions. It does NOT connect to Pluv directly — it's a pure display component. + +**pluv-server.js** handles auth token creation for Pluv.io. All WebSocket traffic goes through Pluv's cloud infrastructure, not this server. + +--- + +## Prerequisites + +### 1. Pluv.io Account + +Sign up at [pluv.io](https://pluv.io) and create a project. You need: +- `PLUV_PUBLISHABLE_KEY` (public, goes to the client) +- `PLUV_SECRET_KEY` (private, stays on the server) + +### 2. MongoDB + +A MongoDB database with two collections: +- `rooms` — stores chat room definitions +- `messages` — stores chat messages + +You can use MongoDB Atlas (free tier works) or a local instance. + +### 3. Environment Variables + +**pluv-server.js** (server-side): +``` +PLUV_PUBLISHABLE_KEY=pk_... +PLUV_SECRET_KEY=sk_... +PORT=3006 # optional, defaults to 3006 +``` + +**Lowcoder client** (build-time or runtime): +``` +VITE_PLUV_PUBLIC_KEY=pk_... +VITE_PLUV_AUTH_URL=/api/auth/pluv # optional, defaults to this +``` + +### 4. Start the Pluv Server + +```bash +cd client/packages/lowcoder +node pluv-server.js +``` + +Verify it's running: +```bash +curl http://localhost:3006/health +``` + +--- + +## MongoDB Setup + +### Create the Collections + +In your MongoDB database, create two collections. No special indexes are required for basic use, but recommended indexes are shown below. + +### `rooms` Collection + +Each document represents a chat room: + +```json +{ + "_id": "room_general", + "id": "room_general", + "name": "General", + "type": "public", + "description": "Main chat room for everyone", + "members": ["user_alice", "user_bob"], + "createdBy": "user_alice", + "createdAt": 1710600000000, + "llmQueryName": null +} +``` + +Insert a seed room so you have something to start with: + +```javascript +db.rooms.insertOne({ + id: "room_general", + name: "General", + type: "public", + description: "Main chat room", + members: [], + createdBy: "system", + createdAt: Date.now(), + llmQueryName: null +}) +``` + +### `messages` Collection + +Each document represents a single message: + +```json +{ + "_id": "msg_abc123", + "id": "msg_abc123", + "roomId": "room_general", + "text": "Hello everyone!", + "authorId": "user_alice", + "authorName": "Alice", + "timestamp": 1710600005000 +} +``` + +Recommended index for fast message loading: + +```javascript +db.messages.createIndex({ roomId: 1, timestamp: 1 }) +``` + +--- + +## Step-by-Step Setup in Lowcoder + +### Step 1: Add a MongoDB Data Source + +Go to **Settings → Data Sources → New Data Source → MongoDB**. Configure your connection string. + +### Step 2: Create the Queries + +You need 4 queries. Create them in the query panel of your app. + +#### Query: `loadRooms` + +Loads all rooms from MongoDB. + +- **Type**: MongoDB +- **Action**: Find +- **Collection**: `rooms` +- **Query**: `{}` + +This returns an array like `[{ id, name, type, members, ... }, ...]`. + +#### Query: `loadMessages` + +Loads messages for the current room. + +- **Type**: MongoDB +- **Action**: Find +- **Collection**: `messages` +- **Query**: `{ "roomId": "{{chatController1.currentRoomId}}" }` +- **Sort**: `{ "timestamp": 1 }` + +This returns messages sorted oldest-first for the active room. + +#### Query: `saveMessage` + +Inserts a new message into MongoDB. + +- **Type**: MongoDB +- **Action**: Insert One +- **Collection**: `messages` +- **Document**: + +```json +{ + "id": "msg_{{Date.now()}}_{{Math.random().toString(36).slice(2,9)}}", + "roomId": "{{chatController1.currentRoomId}}", + "text": "{{chatBox1.lastSentMessageText}}", + "authorId": "{{chatController1.userId}}", + "authorName": "{{chatController1.userName}}", + "timestamp": {{Date.now()}} +} +``` + +#### Query: `createRoom` + +Inserts a new room into MongoDB. + +- **Type**: MongoDB +- **Action**: Insert One +- **Collection**: `rooms` +- **Document**: + +```json +{ + "id": "room_{{Date.now()}}_{{Math.random().toString(36).slice(2,9)}}", + "name": "{{chatBox1.newRoomName}}", + "type": "{{chatBox1.newRoomType}}", + "description": "{{chatBox1.newRoomDescription}}", + "members": ["{{chatController1.userId}}"], + "createdBy": "{{chatController1.userId}}", + "createdAt": {{Date.now()}}, + "llmQueryName": null +} +``` + +### Step 3: Add the Components + +Drag these onto your canvas from the Insert panel: + +1. **ChatController** (found under Hooks in the insert panel — it's non-visual) +2. **ChatBox V2** (found under Components) + +### Step 4: Configure ChatController + +Select the ChatController in the component tree and set these properties: + +| Property | Value | +|---|---| +| Application ID | `{{currentUser.applicationId}}` or any fixed string like `"my_chat_app"` | +| User ID | `{{currentUser.id}}` or `{{currentUser.email}}` | +| User Name | `{{currentUser.name}}` | + +### Step 5: Configure ChatBox + +Select the ChatBox and set these property bindings: + +**Basic section:** + +| Property | Binding | +|---|---| +| Messages | `{{loadMessages.data}}` | +| Current User ID | `{{chatController1.userId}}` | +| Current User Name | `{{chatController1.userName}}` | + +**Rooms Panel section:** + +| Property | Binding | +|---|---| +| Rooms | `{{chatController1.sharedState.rooms \|\| []}}` | +| Current Room ID | `{{chatController1.currentRoomId}}` | +| Online Users | `{{chatController1.onlineUsers}}` | + +**Real-time section:** + +| Property | Binding | +|---|---| +| Typing Users | `{{chatController1.typingUsers}}` | +| AI Is Thinking | `{{chatController1.aiThinkingRooms[chatController1.currentRoomId]}}` | + +### Step 6: Wire the Events + +This is where the magic happens. Select the ChatBox and add event handlers: + +#### ChatBox Events + +| Event | Action | +|---|---| +| **Message Sent** | Run query `saveMessage` | +| **Message Sent** (2nd handler) | Run query `saveMessage` → on success chain: `chatController1.setRoomData(chatController1.currentRoomId, "lastMessage", { text: chatBox1.lastSentMessageText, authorId: chatController1.userId, ts: Date.now() })` | +| **Start Typing** | `chatController1.startTyping()` | +| **Stop Typing** | `chatController1.stopTyping()` | +| **Room Switch** | `chatController1.switchRoom(chatBox1.pendingRoomId)` | +| **Room Create** | Run query `createRoom` → on success chain: run `loadRooms` → on success: `chatController1.setSharedState("rooms", loadRooms.data)` | + +#### ChatController Events + +| Event | Action | +|---|---| +| **Connected** | Run query `loadRooms` → on success: `chatController1.setSharedState("rooms", loadRooms.data)` | +| **Room Switched** | Run query `loadMessages` | +| **Room Data Changed** | Run query `loadMessages` | + +--- + +## Complete Flow: How It All Works + +### Flow 1: App Opens — Loading Rooms + +``` +1. User opens the app +2. ChatController connects to Pluv.io → "Connected" event fires +3. Connected event handler runs loadRooms query +4. loadRooms returns rooms from MongoDB +5. Handler calls: chatController1.setSharedState("rooms", loadRooms.data) +6. Rooms are now in the YJS shared state + + Meanwhile, for other users already connected: + → YJS auto-syncs the shared state + → Their chatController1.sharedState.rooms updates instantly + → ChatBox re-renders with the room list + → They did NOT run any query — they got the data via YJS +``` + +### Flow 2: User Switches to a Room + +``` +1. User clicks "General" room in the sidebar +2. ChatBox fires "Room Switch" event with pendingRoomId = "room_general" +3. Event handler calls: chatController1.switchRoom("room_general") +4. ChatController updates currentRoomId and presence +5. "Room Switched" event fires +6. Event handler runs loadMessages query (filtered by currentRoomId) +7. loadMessages returns messages from MongoDB +8. ChatBox displays them (bound to {{ loadMessages.data }}) +``` + +### Flow 3: User Sends a Message — Other Users See It + +This is the key flow. Here's what happens step by step: + +``` +USER A (sender): + +1. Alice types "Hello!" and presses Send +2. ChatBox fires "Message Sent" event +3. Event handler runs saveMessage query + → Inserts { id, roomId, text: "Hello!", authorId: "alice", ... } into MongoDB +4. On saveMessage success, handler calls: + chatController1.setRoomData("room_general", "lastMessage", { + text: "Hello!", + authorId: "alice", + ts: 1710600005000 + }) +5. This writes a tiny JSON object to the YJS shared doc under roomData + +USER B (receiver): + +6. YJS auto-syncs the roomData change to Bob's browser +7. chatController1.roomData updates → "Room Data Changed" event fires +8. Event handler runs loadMessages query +9. loadMessages fetches the latest messages from MongoDB (including Alice's new message) +10. ChatBox re-renders with the new message visible + +Total time: ~100-300ms (YJS sync) + ~200-500ms (MongoDB query) +``` + +**What's happening under the hood:** +- Alice does NOT call any "broadcast" method. She just writes a tiny JSON to `roomData`. +- YJS (CRDT) syncs that JSON to all connected users automatically. +- Bob's browser reacts to the roomData change by reloading messages from MongoDB. +- The actual message lives in MongoDB (persistent, queryable). YJS only carries the "something changed" signal as a side effect of the data write. + +### Flow 4: Creating a Room — Other Users See It + +``` +USER A: + +1. Alice clicks "Create Room" → fills in name "Design Team" → submits +2. ChatBox fires "Room Create" event +3. Event handler runs createRoom query (inserts into MongoDB) +4. On success, runs loadRooms query (fetches all rooms) +5. On success, calls: chatController1.setSharedState("rooms", loadRooms.data) + +USER B: + +6. YJS auto-syncs sharedState.rooms to Bob's browser +7. chatController1.sharedState.rooms updates +8. ChatBox re-renders — "Design Team" room appears in the sidebar +9. Bob did NOT run any query — the room list came through YJS +``` + +### Flow 5: Typing Indicators + +``` +1. Alice starts typing in the message input +2. ChatBox fires "Start Typing" event +3. Event handler calls chatController1.startTyping() +4. Pluv presence updates: { userId: "alice", typing: true, currentRoomId: "room_general" } +5. Bob's chatController1.typingUsers updates: [{ userId: "alice", userName: "Alice" }] +6. ChatBox shows "Alice is typing..." indicator + +7. Alice stops typing (pauses or clears input) +8. ChatBox fires "Stop Typing" event +9. Event handler calls chatController1.stopTyping() +10. Bob's typingUsers becomes [] → indicator disappears +``` + +### Flow 6: Sending Invisible JSON Data in a Room + +This is NOT a chat message — it's arbitrary JSON that all room members can read. Use cases: live dashboards, game state, form data, IoT readings, etc. + +``` +USER A (e.g. a dashboard admin): + +1. A query returns KPI data. On success: + chatController1.setRoomData("room_sales", "kpi", { + revenue: 142000, + deals: 17, + updated: "2026-03-16T10:30:00Z" + }) + +USER B (e.g. a sales rep viewing the room): + +2. YJS auto-syncs roomData +3. Any component bound to {{ chatController1.roomData.room_sales.kpi.revenue }} + instantly shows: 142000 +4. When User A updates the KPI, User B's UI updates in real-time + +No messages. No events to wire. Just reactive data binding. +``` + +--- + +## API Reference + +### ChatController — Properties (read via bindings) + +| Property | Type | Description | +|---|---|---| +| `ready` | `boolean` | `true` when connected to Pluv | +| `connectionStatus` | `string` | `"Online"`, `"Connecting..."`, or `"Offline"` | +| `error` | `string \| null` | Error message if connection failed | +| `userId` | `string` | Current user ID | +| `userName` | `string` | Current user name | +| `applicationId` | `string` | Application scope ID | +| `currentRoomId` | `string \| null` | Currently active room | +| `onlineUsers` | `Array<{ userId, userName, currentRoomId }>` | Who's online | +| `typingUsers` | `Array<{ userId, userName, roomId }>` | Who's typing | +| `aiThinkingRooms` | `{ [roomId]: boolean }` | Which rooms have AI thinking | +| `sharedState` | `object` | App-level shared JSON — auto-syncs across all users | +| `roomData` | `{ [roomId]: { [key]: value } }` | Room-scoped shared JSON — auto-syncs | + +### ChatController — Methods (call from event handlers) + +| Method | Params | Description | +|---|---|---| +| `setSharedState(key, value)` | `key: string`, `value: any` | Write to app-level shared state. All users see the update instantly. | +| `deleteSharedState(key)` | `key: string` | Remove a key from shared state. | +| `setRoomData(roomId, key, value)` | `roomId: string`, `key: string`, `value: any` | Write JSON scoped to a room. Not visible as a chat message. | +| `deleteRoomData(roomId, key?)` | `roomId: string`, `key?: string` | Remove a key (or all data) from a room. | +| `switchRoom(roomId)` | `roomId: string` | Set the active room. Updates presence and fires `roomSwitched`. | +| `startTyping(roomId?)` | `roomId?: string` | Show typing indicator to other users. | +| `stopTyping()` | — | Hide typing indicator. | +| `setAiThinking(roomId, isThinking)` | `roomId: string`, `isThinking: boolean` | Show/hide AI thinking animation for a room. | +| `setUser(userId, userName)` | `userId: string`, `userName: string` | Update user credentials at runtime. | + +### ChatController — Events + +| Event | When it fires | +|---|---| +| `Connected` | WebSocket connection established | +| `Disconnected` | WebSocket connection lost | +| `Error` | Connection error occurred | +| `User Joined` | A new user came online | +| `User Left` | A user went offline | +| `Room Switched` | Active room changed (after `switchRoom()`) | +| `Shared State Changed` | Any key in `sharedState` was updated by any user | +| `Room Data Changed` | Any key in `roomData` was updated by any user | +| `AI Thinking Started` | AI started generating in a room | +| `AI Thinking Stopped` | AI finished generating in a room | + +### ChatBox — Properties (set in property panel) + +| Property | Binding | Description | +|---|---|---| +| `messages` | `{{loadMessages.data}}` | Array of message objects | +| `rooms` | `{{chatController1.sharedState.rooms \|\| []}}` | Array of room objects | +| `currentRoomId` | `{{chatController1.currentRoomId}}` | Active room ID | +| `currentUserId` | `{{chatController1.userId}}` | Current user's ID | +| `currentUserName` | `{{chatController1.userName}}` | Current user's name | +| `typingUsers` | `{{chatController1.typingUsers}}` | Users currently typing | +| `onlineUsers` | `{{chatController1.onlineUsers}}` | Users currently online | +| `isAiThinking` | `{{chatController1.aiThinkingRooms[chatController1.currentRoomId]}}` | AI thinking state | +| `showRoomsPanel` | `true` / `false` | Toggle room sidebar | +| `allowRoomCreation` | `true` / `false` | Show create-room button | + +### ChatBox — Events + +| Event | What to do | +|---|---| +| `Message Sent` | Run `saveMessage` query, then update roomData | +| `Start Typing` | Call `chatController1.startTyping()` | +| `Stop Typing` | Call `chatController1.stopTyping()` | +| `Room Switch` | Call `chatController1.switchRoom(chatBox1.pendingRoomId)` | +| `Room Create` | Run `createRoom` query, reload rooms, update sharedState | +| `Room Join` | Add user to room members in DB, reload rooms | +| `Room Leave` | Remove user from room members, reload rooms | + +### ChatBox — Exposed State (read from other components) + +| Property | Description | +|---|---| +| `lastSentMessageText` | The text of the last message the user sent | +| `messageText` | Current text in the input field | +| `pendingRoomId` | Room ID from the last room switch/join/leave click | +| `newRoomName` | Room name from the create-room form | +| `newRoomType` | Room type from the create-room form (`public` / `private` / `llm`) | +| `newRoomDescription` | Description from the create-room form | +| `inviteTargetUserId` | User ID from the invite form | +| `pendingInviteId` | Invite ID from accept/decline | + +--- + +## Data Shapes + +### Message Object + +```json +{ + "id": "msg_1710600005000_a3kf8j2", + "roomId": "room_general", + "text": "Hello everyone!", + "authorId": "user_alice", + "authorName": "Alice", + "timestamp": 1710600005000, + "authorType": "user" +} +``` + +`authorType` is optional. Set to `"assistant"` for AI/bot messages to render them with a different bubble style. + +### Room Object + +```json +{ + "id": "room_general", + "name": "General", + "type": "public", + "description": "Main chat room", + "members": ["user_alice", "user_bob"], + "createdBy": "user_alice", + "createdAt": 1710600000000, + "llmQueryName": null +} +``` + +`type` can be `"public"`, `"private"`, or `"llm"` (for AI-powered rooms). + +--- + +## Shared State vs Room Data — When to Use Which + +| Scenario | Use | Example | +|---|---|---| +| Room list visible to all users | `setSharedState("rooms", [...])` | Syncs the room sidebar | +| App-wide config or settings | `setSharedState("config", {...})` | Theme, feature flags | +| Any app-wide data | `setSharedState("myKey", value)` | Announcements, counters | +| Invisible JSON data in a room | `setRoomData(roomId, "key", {...})` | KPI dashboard, game state | +| Signal that a message was sent | `setRoomData(roomId, "lastMessage", {...})` | Triggers other users to reload | +| IoT / live sensor data in a room | `setRoomData(roomId, "sensors", {...})` | Real-time feeds | + +**Rule of thumb**: If ALL users need it regardless of room → `sharedState`. If it's scoped to a specific room/channel → `roomData`. + +--- + +## Memory and Performance Notes + +- **sharedState** and **roomData** use YJS CRDT (via Pluv.io). The data is kept in memory on each connected client. +- Keep shared data small — room metadata, config, signals. A few KB is ideal, up to ~100KB is fine. +- **Do NOT put full message history into shared state.** Messages belong in MongoDB. YJS is for small, frequently-updated JSON that needs real-time sync. +- When you overwrite a key (`setRoomData("room_1", "kpi", newData)`), YJS garbage-collects the old value. The doc size stays proportional to current data, not history. +- Each user downloads the full YJS doc on connect. For a typical chat app with ~10-20 rooms and small per-room data, the doc is under 10KB. + +--- + +## Troubleshooting + +### ChatController shows "Connecting..." forever + +- Check that `pluv-server.js` is running and reachable +- Verify `VITE_PLUV_PUBLIC_KEY` is set correctly +- Check browser console for auth errors +- If using a proxy (Vite dev server), ensure `/api/auth/pluv` is proxied to port 3006 + +### Messages don't appear for other users + +- Verify the `saveMessage` query is succeeding (check query results) +- Verify you're calling `setRoomData(roomId, "lastMessage", ...)` after save +- Verify the ChatController has a `Room Data Changed` event handler that runs `loadMessages` +- Make sure both users have the same `applicationId` (they must be in the same Pluv room) + +### Rooms don't sync across users + +- After creating a room, you must call `chatController1.setSharedState("rooms", loadRooms.data)` +- The rooms don't come from the DB automatically — you push them to shared state, then YJS syncs them + +### "Room Data Changed" fires but loadMessages returns empty + +- Check that `chatController1.currentRoomId` is set (user must have switched to a room) +- Check that the `loadMessages` query filter uses `chatController1.currentRoomId` + +--- + +## Quick Start Checklist + +1. [ ] Pluv.io account created, keys obtained +2. [ ] `pluv-server.js` running with env vars set +3. [ ] MongoDB data source configured in Lowcoder +4. [ ] `rooms` and `messages` collections created in MongoDB +5. [ ] Seed room inserted (`room_general`) +6. [ ] Queries created: `loadRooms`, `loadMessages`, `saveMessage`, `createRoom` +7. [ ] ChatController added, configured with applicationId / userId / userName +8. [ ] ChatBox added, properties bound to ChatController + queries +9. [ ] ChatBox events wired: messageSent, startTyping, stopTyping, roomSwitch, roomCreate +10. [ ] ChatController events wired: connected, roomSwitched, roomDataChanged +11. [ ] Open app in two browser windows with different users — test sending messages diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/UPDATED_MESSAGE_SENT_EXAMPLE.js b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/UPDATED_MESSAGE_SENT_EXAMPLE.js new file mode 100644 index 000000000..05fc760ff --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/UPDATED_MESSAGE_SENT_EXAMPLE.js @@ -0,0 +1,43 @@ +// UPDATED MessageSent Query Code +// Replaces broadcastNewMessage with setRoomData + +const currentRoomId = chatControllerSignal1.currentRoomId; +const rooms = chatControllerSignal1.sharedState?.rooms || []; +const currentRoom = rooms.find(r => r.id === currentRoomId); + +console.log("CURRENT ROOM", currentRoom); + +saveMessage.run() + .then(() => { + // Check if current room is an LLM room + if (currentRoom && currentRoom.type === 'llm') { + console.log("STARTING AI THINKING..."); + // Broadcast to all users: AI is thinking + chatControllerSignal1.setAiThinking(currentRoomId, true); + return getAIResponse.run(); + } + }) + .then(() => { + // AI finished - stop thinking animation + if (currentRoom && currentRoom.type === 'llm') { + console.log("AI THINKING STOPPED"); + chatControllerSignal1.setAiThinking(currentRoomId, false); + } + + // NEW: Signal other users that a message was saved + // This triggers their "Room Data Changed" event which reloads messages + chatControllerSignal1.setRoomData(currentRoomId, "lastMessage", { + ts: Date.now(), + authorId: chatControllerSignal1.userId + }); + + // Reload your own messages + return loadMessages.run(); + }) + .catch(err => { + // Stop thinking on error so it doesn't get stuck + if (currentRoom && currentRoom.type === 'llm') { + chatControllerSignal1.setAiThinking(currentRoomId, false); + } + console.error("Error:", err); + }); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx index 13a4b7a84..adf39e5e5 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx @@ -3,6 +3,8 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Tooltip } from "antd"; import { CopyOutlined, CheckOutlined, RobotOutlined } from "@ant-design/icons"; +import dayjs from "dayjs"; +import { parseMessageTimestamp, formatChatTime } from "util/dateTimeUtils"; import { LLM_BOT_AUTHOR_ID } from "../store"; import { MessagesArea, @@ -21,10 +23,17 @@ import { LlmLoadingBubble, } from "../styles"; +function readField(msg: any, ...keys: string[]): string { + for (const k of keys) { + if (msg[k] != null && msg[k] !== "") return String(msg[k]); + } + return ""; +} + // ── AI message bubble with copy button ─────────────────────────────────────── const AiMessageBubble = React.memo( - ({ text, authorName, timestamp }: { text: string; authorName: string; timestamp: number }) => { + ({ text, authorName, ts }: { text: string; authorName: string; ts: dayjs.Dayjs | null }) => { const [copied, setCopied] = useState(false); const handleCopy = useCallback(() => { @@ -58,12 +67,9 @@ const AiMessageBubble = React.memo(
- {timestamp > 0 && ( + {ts && ( - {new Date(timestamp).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} + {formatChatTime(ts)} )} @@ -73,23 +79,6 @@ const AiMessageBubble = React.memo( AiMessageBubble.displayName = "AiMessageBubble"; -// ── Helpers to read message fields flexibly ─────────────────────────────────── - -function readField(msg: any, ...keys: string[]): string { - for (const k of keys) { - if (msg[k] != null && msg[k] !== "") return String(msg[k]); - } - return ""; -} - -function readTimestamp(msg: any): number { - const raw = - msg.timestamp ?? msg.createdAt ?? msg.created_at ?? msg.time ?? 0; - if (typeof raw === "number") return raw; - const parsed = Date.parse(raw); - return Number.isNaN(parsed) ? 0 : parsed; -} - // ── Main component ─────────────────────────────────────────────────────────── export interface MessageListProps { @@ -139,7 +128,7 @@ export const MessageList = React.memo((props: MessageListProps) => { "author_name", "senderName", ) || authorId; - const timestamp = readTimestamp(msg); + const ts = parseMessageTimestamp(msg); const authorType = msg.authorType || msg.role || ""; const isAssistant = @@ -153,7 +142,7 @@ export const MessageList = React.memo((props: MessageListProps) => { key={id} text={text} authorName={authorName} - timestamp={timestamp} + ts={ts} /> ); } @@ -162,12 +151,9 @@ export const MessageList = React.memo((props: MessageListProps) => { {authorName} {text} - {timestamp > 0 && ( + {ts && ( - {new Date(timestamp).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} + {formatChatTime(ts)} )} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts index c8d29737e..f54203df5 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts @@ -5,7 +5,6 @@ export type { PendingRoomInvite, TypingUser, OnlineUser, - MessageBroadcast, } from "./types"; export { uid, LLM_BOT_AUTHOR_ID } from "./types"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts index 241eff3d1..a113bd6f5 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts @@ -36,8 +36,9 @@ const client = createClient({ return `${PLUV_AUTH_URL}?${params}`; }, initialStorage: yjs.doc((t: any) => ({ - messageActivity: t.map("messageActivity", []), aiActivity: t.map("aiActivity", []), + sharedState: t.map("sharedState", []), + roomData: t.map("roomData", []), })), presence: z.object({ userId: z.string(), diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts index f2a2ef7be..d26e7f2f3 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts @@ -41,15 +41,6 @@ export interface OnlineUser { currentRoomId: string | null; } -export interface MessageBroadcast { - roomId: string; - messageId: string; - authorId: string; - authorName: string; - timestamp: number; - counter: number; -} - export interface AiThinkingState { roomId: string; isThinking: boolean; diff --git a/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx index 022bcc485..db4ea4b9b 100644 --- a/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx +++ b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx @@ -12,6 +12,7 @@ import { withMethodExposing } from "../generators/withMethodExposing"; import { stringExposingStateControl } from "comps/controls/codeStateControl"; import { eventHandlerControl } from "comps/controls/eventHandlerControl"; import { JSONObject } from "../../util/jsonTypes"; +import { isEmpty, omit, isEqual } from "lodash"; import { PluvRoomProvider, useStorage, @@ -21,7 +22,6 @@ import { } from "../comps/chatBoxComponentv2/store"; import type { AiThinkingState, - MessageBroadcast, OnlineUser, TypingUser, } from "../comps/chatBoxComponentv2/store"; @@ -29,11 +29,6 @@ import type { // ─── Event definitions ────────────────────────────────────────────────────── const ChatControllerEvents = [ - { - label: "New Message Broadcast", - value: "newMessageBroadcast", - description: "A peer broadcast that a new message was saved — reload your data query to fetch it", - }, { label: "User Joined", value: "userJoined", @@ -74,6 +69,18 @@ const ChatControllerEvents = [ value: "aiThinkingStopped", description: "The AI assistant finished (or was cancelled) in a room", }, + { + label: "Shared State Changed", + value: "sharedStateChanged", + description: + "The app-level shared state was updated by any user. Read chatController.sharedState to get the current state.", + }, + { + label: "Room Data Changed", + value: "roomDataChanged", + description: + "Room-scoped shared data was updated by any user. Read chatController.roomData to get the current data.", + }, ] as const; // ─── Children map ─────────────────────────────────────────────────────────── @@ -91,8 +98,9 @@ const childrenMap = { onlineUsers: stateComp([]), typingUsers: stateComp([]), currentRoomId: stateComp(null), - lastMessageNotification: stateComp(null), aiThinkingRooms: stateComp({}), + sharedState: stateComp({}), + roomData: stateComp({}), _signalActions: stateComp({}), }; @@ -100,11 +108,14 @@ const childrenMap = { // ─── Signal actions interface ──────────────────────────────────────────────── interface SignalActions { - broadcastNewMessage: (roomId: string, messageId?: string) => void; startTyping: (roomId?: string) => void; stopTyping: () => void; switchRoom: (roomId: string) => void; setAiThinking: (roomId: string, isThinking: boolean) => void; + setSharedState: (key: string, value: any) => void; + deleteSharedState: (key: string) => void; + setRoomData: (roomId: string, key: string, value: any) => void; + deleteRoomData: (roomId: string, key?: string) => void; } // ─── Inner component that uses Pluv hooks inside PluvRoomProvider ──────────── @@ -120,8 +131,9 @@ const SignalController = React.memo( const connection = useConnection(); const [, setMyPresence] = useMyPresence(); const others = useOthers(); - const [messageActivity, messageActivityYMap] = useStorage("messageActivity"); const [aiActivity, aiActivityYMap] = useStorage("aiActivity"); + const [sharedStateData, sharedStateYMap] = useStorage("sharedState"); + const [roomDataData, roomDataYMap] = useStorage("roomData"); const compRef = useRef(comp); compRef.current = comp; @@ -133,15 +145,17 @@ const SignalController = React.memo( const prevRef = useRef<{ ready: boolean; onlineCount: number; - lastBroadcastCounter: Record; initialized: boolean; aiThinkingRooms: Record; + sharedState: JSONObject | null; + roomData: JSONObject | null; }>({ ready: false, onlineCount: 0, - lastBroadcastCounter: {}, initialized: false, aiThinkingRooms: {}, + sharedState: null, + roomData: null, }); // ── Connection state ────────────────────────────────────────────── @@ -215,26 +229,6 @@ const SignalController = React.memo( ); }, [typingUsers]); - // ── Watch message activity for broadcasts from other users ──────── - useEffect(() => { - if (!messageActivity) return; - const activityRecord = messageActivity as Record; - - for (const [roomId, activity] of Object.entries(activityRecord)) { - const prevCounter = - prevRef.current.lastBroadcastCounter[roomId] || 0; - if (activity.counter > prevCounter) { - prevRef.current.lastBroadcastCounter[roomId] = activity.counter; - if (activity.authorId !== userId) { - compRef.current.children.lastMessageNotification.dispatchChangeValueAction( - activity as unknown as JSONObject, - ); - triggerEventRef.current("newMessageBroadcast"); - } - } - } - }, [messageActivity, userId]); - // ── Watch AI activity (thinking state per room) ─────────────── useEffect(() => { if (!aiActivity) return; @@ -257,27 +251,31 @@ const SignalController = React.memo( ); }, [aiActivity]); - // ── Actions for method invocation ───────────────────────────────── + // ── Watch shared state ────────────────────────────────────────── + useEffect(() => { + if (!sharedStateData) return; + const next = sharedStateData as unknown as JSONObject; + if (isEqual(next, prevRef.current.sharedState)) return; + prevRef.current.sharedState = next; + compRef.current.children.sharedState.dispatchChangeValueAction(next); + if (prevRef.current.initialized) { + triggerEventRef.current("sharedStateChanged"); + } + }, [sharedStateData]); - const broadcastNewMessage = useCallback( - (roomId: string, messageId?: string) => { - if (!messageActivityYMap) return; - const existing = messageActivityYMap.get(roomId) as - | MessageBroadcast - | undefined; - const broadcast: MessageBroadcast = { - roomId, - messageId: messageId || crypto.randomUUID(), - authorId: userId, - authorName: userName, - timestamp: Date.now(), - counter: (existing?.counter || 0) + 1, - }; - messageActivityYMap.set(roomId, broadcast); - prevRef.current.lastBroadcastCounter[roomId] = broadcast.counter; - }, - [messageActivityYMap, userId, userName], - ); + // ── Watch room data ────────────────────────────────────────────── + useEffect(() => { + if (!roomDataData) return; + const next = roomDataData as unknown as JSONObject; + if (isEqual(next, prevRef.current.roomData)) return; + prevRef.current.roomData = next; + compRef.current.children.roomData.dispatchChangeValueAction(next); + if (prevRef.current.initialized) { + triggerEventRef.current("roomDataChanged"); + } + }, [roomDataData]); + + // ── Actions for method invocation ───────────────────────────────── const startTyping = useCallback( (roomId?: string) => { @@ -327,29 +325,83 @@ const SignalController = React.memo( [aiActivityYMap], ); + // ── Shared state actions ───────────────────────────────────────── + const setSharedState = useCallback( + (key: string, value: any) => { + if (!sharedStateYMap) return; + sharedStateYMap.set(key, value); + }, + [sharedStateYMap], + ); + + const deleteSharedState = useCallback( + (key: string) => { + if (!sharedStateYMap) return; + sharedStateYMap.delete(key); + }, + [sharedStateYMap], + ); + + // ── Room data actions ──────────────────────────────────────────── + const setRoomData = useCallback( + (roomId: string, key: string, value: any) => { + if (!roomDataYMap) return; + const existing = (roomDataYMap.get(roomId) as Record) || {}; + roomDataYMap.set(roomId, { ...existing, [key]: value }); + }, + [roomDataYMap], + ); + + const deleteRoomData = useCallback( + (roomId: string, key?: string) => { + if (!roomDataYMap) return; + if (key) { + const existing = (roomDataYMap.get(roomId) as Record) || {}; + const remaining = omit(existing, key); + if (isEmpty(remaining)) { + roomDataYMap.delete(roomId); + } else { + roomDataYMap.set(roomId, remaining); + } + } else { + roomDataYMap.delete(roomId); + } + }, + [roomDataYMap], + ); + // ── Proxy ref for stable callbacks ──────────────────────────────── const actionsRef = useRef({ - broadcastNewMessage, startTyping, stopTyping, switchRoom, setAiThinking, + setSharedState, + deleteSharedState, + setRoomData, + deleteRoomData, }); actionsRef.current = { - broadcastNewMessage, startTyping, stopTyping, switchRoom, setAiThinking, + setSharedState, + deleteSharedState, + setRoomData, + deleteRoomData, }; useEffect(() => { const proxy: SignalActions = { - broadcastNewMessage: (...args) => actionsRef.current.broadcastNewMessage(...args), startTyping: (...args) => actionsRef.current.startTyping(...args), stopTyping: () => actionsRef.current.stopTyping(), switchRoom: (...args) => actionsRef.current.switchRoom(...args), setAiThinking: (...args) => actionsRef.current.setAiThinking(...args), + setSharedState: (...args) => actionsRef.current.setSharedState(...args), + deleteSharedState: (...args) => actionsRef.current.deleteSharedState(...args), + setRoomData: (...args) => actionsRef.current.setRoomData(...args), + deleteRoomData: (...args) => actionsRef.current.deleteRoomData(...args), }; compRef.current.children._signalActions.dispatchChangeValueAction( proxy as unknown as JSONObject, @@ -396,8 +448,9 @@ const ChatControllerSignalBase = withViewFn( } as any } initialStorage={(t: any) => ({ - messageActivity: t.map("messageActivity", []), aiActivity: t.map("aiActivity", []), + sharedState: t.map("sharedState", []), + roomData: t.map("roomData", []), })} onAuthorizationFail={(error: Error) => { console.error("[ChatControllerV2] Auth failed:", error); @@ -461,10 +514,6 @@ let ChatControllerSignal = withExposingConfigs(ChatControllerSignalWithProps, [ "Array of users currently typing: [{ userId, userName, roomId }]", ), new NameConfig("currentRoomId", "Currently active room/channel ID"), - new NameConfig( - "lastMessageNotification", - "Last message broadcast received from a peer: { roomId, messageId, authorId, authorName, timestamp }", - ), new NameConfig("userId", "Current user ID"), new NameConfig("userName", "Current user name"), new NameConfig("applicationId", "Application scope ID"), @@ -472,31 +521,19 @@ let ChatControllerSignal = withExposingConfigs(ChatControllerSignalWithProps, [ "aiThinkingRooms", "Map of roomId → boolean indicating which rooms have an AI currently thinking. E.g. { 'room_123': true }", ), + new NameConfig( + "sharedState", + "App-level shared state (JSON) that auto-syncs across all connected users. Write with setSharedState(key, value).", + ), + new NameConfig( + "roomData", + "Room-scoped shared data (JSON) that auto-syncs. Structure: { roomId: { key: value } }. Not visible as chat messages. Write with setRoomData(roomId, key, value).", + ), ]); // ─── Expose methods ───────────────────────────────────────────────────────── ChatControllerSignal = withMethodExposing(ChatControllerSignal, [ - { - method: { - name: "broadcastNewMessage", - description: - "Broadcast to all peers that a new message was saved. Other users' onNewMessageBroadcast event fires so they can reload their data query.", - params: [ - { name: "roomId", type: "string" }, - { name: "messageId", type: "string" }, - ], - }, - execute: (comp, values) => { - const actions = comp.children._signalActions.getView() as unknown as SignalActions; - if (actions?.broadcastNewMessage) { - actions.broadcastNewMessage( - values?.[0] as string, - values?.[1] as string | undefined, - ); - } - }, - }, { method: { name: "startTyping", @@ -556,6 +593,78 @@ ChatControllerSignal = withMethodExposing(ChatControllerSignal, [ } }, }, + { + method: { + name: "setSharedState", + description: + "Set a key-value pair in the app-level shared state. Auto-syncs to all connected users instantly via CRDT.", + params: [ + { name: "key", type: "string" }, + { name: "value", type: "JSONValue" }, + ], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.setSharedState) { + actions.setSharedState(values?.[0] as string, values?.[1]); + } + }, + }, + { + method: { + name: "deleteSharedState", + description: "Delete a key from the app-level shared state.", + params: [{ name: "key", type: "string" }], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.deleteSharedState) { + actions.deleteSharedState(values?.[0] as string); + } + }, + }, + { + method: { + name: "setRoomData", + description: + "Set a key-value pair in a room's shared data. Auto-syncs to all connected users. Not visible as a chat message — use for real-time JSON data exchange within a room/channel.", + params: [ + { name: "roomId", type: "string" }, + { name: "key", type: "string" }, + { name: "value", type: "JSONValue" }, + ], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.setRoomData) { + actions.setRoomData( + values?.[0] as string, + values?.[1] as string, + values?.[2], + ); + } + }, + }, + { + method: { + name: "deleteRoomData", + description: + "Delete a key from a room's shared data. If no key is provided, deletes all data for the room.", + params: [ + { name: "roomId", type: "string" }, + { name: "key", type: "string" }, + ], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.deleteRoomData) { + actions.deleteRoomData( + values?.[0] as string, + values?.[1] as string | undefined, + ); + } + }, + }, { method: { name: "setUser", diff --git a/client/packages/lowcoder/src/util/dateTimeUtils.ts b/client/packages/lowcoder/src/util/dateTimeUtils.ts index fba3affdc..97f362118 100644 --- a/client/packages/lowcoder/src/util/dateTimeUtils.ts +++ b/client/packages/lowcoder/src/util/dateTimeUtils.ts @@ -114,3 +114,22 @@ export function timestampToHumanReadable( } return timeInfo; } + +export function parseMessageTimestamp(msg: any): dayjs.Dayjs | null { + const raw = msg.timestamp ?? msg.createdAt ?? msg.created_at ?? msg.time; + if (raw == null) return null; + const d = dayjs(raw); + return d.isValid() ? d : null; +} + +export function formatChatTime(d: dayjs.Dayjs): string { + const now = dayjs(); + const diffSeconds = now.diff(d, "second"); + + if (diffSeconds < 60) return "Just now"; + if (diffSeconds < 3600) return d.fromNow(); + if (d.isToday()) return d.format("h:mm A"); + if (d.isYesterday()) return `Yesterday ${d.format("h:mm A")}`; + if (d.isSame(now, "year")) return d.format("MMM D h:mm A"); + return d.format("MMM D, YYYY h:mm A"); +}