diff --git a/client/package.json b/client/package.json index 12f93a4aa7..4738986792 100644 --- a/client/package.json +++ b/client/package.json @@ -56,7 +56,7 @@ "shelljs": "^0.8.5", "svgo": "^3.0.0", "ts-node": "^10.4.0", - "typescript": "^4.8.4", + "typescript": "^5.6.2", "whatwg-fetch": "^3.6.2" }, "lint-staged": { diff --git a/client/packages/lowcoder/hocuspocus-server.js b/client/packages/lowcoder/hocuspocus-server.js new file mode 100644 index 0000000000..a6f773a4af --- /dev/null +++ b/client/packages/lowcoder/hocuspocus-server.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +/** + * Hocuspocus (Yjs WebSocket) Server for ChatBox v2 + * + * Provides real-time shared state via Yjs documents served over WebSocket. + * Each application room maps to a Yjs document. Presence is handled through + * Hocuspocus awareness on the client side. + * + * Env vars: + * PORT - HTTP/WebSocket port (default 3006) + * HOST - Bind address (default 0.0.0.0) + * HOCUSPOCUS_SECRET - Optional shared secret for token auth + * + * Usage: node hocuspocus-server.js + */ + +import { Server } from "@hocuspocus/server"; + +const PORT = parseInt(process.env.PORT || "3006", 10); +const HOST = process.env.HOST || "0.0.0.0"; +const SECRET = process.env.HOCUSPOCUS_SECRET || ""; + +function writeJson(response, statusCode, payload) { + response.writeHead(statusCode, { "Content-Type": "application/json" }); + response.end(JSON.stringify(payload)); +} + +const server = new Server({ + name: "lowcoder-hocuspocus", + quiet: true, + address: HOST, + port: PORT, + + async onListen() { + console.log(`[hocuspocus] listening on ws://${HOST}:${PORT}`); + }, + + async onRequest({ request, response }) { + if (request.url === "/health") { + writeJson(response, 200, { + status: "ok", + host: HOST, + port: PORT, + auth: SECRET ? "enabled" : "disabled", + }); + return; + } + + if (request.url === "/") { + writeJson(response, 200, { + name: "lowcoder-hocuspocus", + websocket: `ws://${HOST}:${PORT}`, + health: "/health", + }); + } + }, + + async onAuthenticate({ token, documentName }) { + if (!SECRET) { + return; + } + + if (token !== SECRET) { + console.warn(`[hocuspocus] rejected connection for ${documentName}: invalid token`); + throw new Error("Unauthorized"); + } + }, + + async onConnect({ documentName, socketId }) { + console.log(`[hocuspocus] connect socket=${socketId} document=${documentName}`); + }, + + async onDisconnect({ documentName, socketId }) { + console.log(`[hocuspocus] disconnect socket=${socketId} document=${documentName}`); + }, +}); + +try { + await server.listen(); +} catch (error) { + console.error("[hocuspocus] failed to start", error); + process.exit(1); +} diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index 9aac99d87b..10e090dd85 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -30,6 +30,8 @@ "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/react-fontawesome": "latest", + "@hocuspocus/provider": "^3.4.4", + "@hocuspocus/server": "^3.4.4", "@jsonforms/core": "^3.5.1", "@lottiefiles/dotlottie-react": "^0.13.0", "@manaflair/redux-batch": "^1.0.0", @@ -59,11 +61,13 @@ "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", "echarts": "^5.4.3", "echarts-for-react": "^3.0.2", "echarts-wordcloud": "^2.1.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", @@ -125,9 +129,11 @@ "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" + "yjs": "^13.6.27", + "zod": "^3.25.76" }, "scripts": { "supportedBrowsers": "yarn dlx browserslist-useragent-regexp --allowHigherVersions '>0.2%,not dead,not op_mini all,chrome >=69'", @@ -153,7 +159,7 @@ "http-proxy-middleware": "^2.0.6", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-visualizer": "^5.9.2", - "typescript": "^4.8.4", + "typescript": "^5.6.2", "vite": "^4.5.5", "vite-plugin-checker": "^0.5.1", "vite-plugin-dynamic-import": "^1.5.0", diff --git a/client/packages/lowcoder/src/app-env.d.ts b/client/packages/lowcoder/src/app-env.d.ts index 5205d43a84..9953e80e09 100644 --- a/client/packages/lowcoder/src/app-env.d.ts +++ b/client/packages/lowcoder/src/app-env.d.ts @@ -41,6 +41,8 @@ declare var REACT_APP_ENV: string; declare var REACT_APP_BUILD_ID: string; declare var REACT_APP_LOG_LEVEL: string; declare var REACT_APP_SERVER_IPS: string; +declare var REACT_APP_HOCUSPOCUS_URL: string; +declare var REACT_APP_HOCUSPOCUS_SECRET: string; declare var REACT_APP_BUNDLE_TYPE: "sdk" | "app"; declare var REACT_APP_DISABLE_JS_SANDBOX: string; declare var REACT_APP_BUNDLE_BUILTIN_PLUGIN: string; diff --git a/client/packages/lowcoder/src/base/codeEditor/completion/exposingCompletionSource.tsx b/client/packages/lowcoder/src/base/codeEditor/completion/exposingCompletionSource.tsx index 056edb7bba..8a3b943537 100644 --- a/client/packages/lowcoder/src/base/codeEditor/completion/exposingCompletionSource.tsx +++ b/client/packages/lowcoder/src/base/codeEditor/completion/exposingCompletionSource.tsx @@ -19,7 +19,7 @@ export class ExposingCompletionSource extends CompletionSource { return null; } const matchPath = context.matchBefore( - /(?:[A-Za-z_$][\w$]*(?:\[\s*(?:\d+|(["'])(?:[^\1\\]|\\.)*?\1)\s*\])*\.)*(?:[A-Za-z_$][\w$]*)?/ + /(?:[A-Za-z_$][\w$]*(?:\[\s*(?:\d+|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\s*\])*\.)*(?:[A-Za-z_$][\w$]*)?/ ); if (!matchPath) { return null; diff --git a/client/packages/lowcoder/src/components/StepModal.tsx b/client/packages/lowcoder/src/components/StepModal.tsx index 13d08319b3..0bc88bf6da 100644 --- a/client/packages/lowcoder/src/components/StepModal.tsx +++ b/client/packages/lowcoder/src/components/StepModal.tsx @@ -25,7 +25,8 @@ export interface StepModalProps extends CustomModalProps { export default function StepModal(props: StepModalProps) { const { steps, activeStepKey, onStepChange, ...modalProps } = props; const [current, setCurrent] = useState(steps[0]?.key); - const currentStepIndex = steps.findIndex((i) => i.key === activeStepKey ?? current); + const currentStepKey = activeStepKey ?? current; + const currentStepIndex = steps.findIndex((i) => i.key === currentStepKey); const currentStep = currentStepIndex >= 0 ? steps[currentStepIndex] : null; const handleChangeStep = (key: string) => { diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/README.md b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/README.md deleted file mode 100644 index 9353f42708..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/README.md +++ /dev/null @@ -1,992 +0,0 @@ -# ChatBoxComponent - Developer Guide - -**A comprehensive real-time chat component for Lowcoder with local and collaborative modes** - ---- - -## ๐Ÿ“‹ **Table of Contents** - -1. [Project Status & Architecture](#project-status--architecture) -2. [Component Structure](#component-structure) -3. [Features Implemented](#features-implemented) -4. [Development Setup](#development-setup) -5. [Testing & Debugging](#testing--debugging) -6. [Architecture Deep Dive](#architecture-deep-dive) -7. [API Reference](#api-reference) -8. [Future Enhancements](#future-enhancements) -9. [Known Issues & Limitations](#known-issues--limitations) -10. [Contributing Guidelines](#contributing-guidelines) - ---- - -## ๐ŸŽฏ **Project Status & Architecture** - -### **Current Status: โœ… PRODUCTION READY** - -The ChatBoxComponent is **fully functional** with real-time synchronization, local persistence, and dynamic room management. All major features are implemented and tested. - -### **High-Level Architecture** - -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ ChatBoxComponent (React) โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ useChatManager โ”‚ โ”‚ -โ”‚ โ”‚ (React Hook) โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ HybridChatManager โ”‚ -โ”‚ (Provider Coordination Layer) โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ ALASqlProvider โ”‚ โ”‚ YjsPluvProvider โ”‚ โ”‚ -โ”‚ โ”‚ (Local Storage) โ”‚ โ”‚ (Real-time Collaboration) โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ€ข SQLite-like DB โ”‚ โ”‚ โ€ข Yjs CRDT Documents โ”‚ โ”‚ -โ”‚ โ”‚ โ€ข Local Persistence โ”‚ โ”‚ โ€ข WebSocket Synchronization โ”‚ โ”‚ -โ”‚ โ”‚ โ€ข Cross-tab Sharing โ”‚ โ”‚ โ€ข Real-time Presence โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ€ข Typing Indicators โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -### **๐Ÿ”ง Recent Major Fixes** - -**Critical Issues Resolved:** -- **โœ… WebSocket Server**: Fixed import issues with `y-websocket` server utilities -- **โœ… Connection Management**: Proper lifecycle handling with status monitoring -- **โœ… Memory Management**: Reference counting for shared Yjs documents -- **โœ… Observer Cleanup**: Fixed memory leaks in event subscription handling -- **โœ… Cross-browser Sync**: Real-time synchronization across multiple browsers - ---- - -## ๐Ÿ“ **Component Structure** - -### **File Organization** - -``` -chatBoxComponent/ -โ”œโ”€โ”€ README.md # This file - comprehensive developer guide -โ”œโ”€โ”€ index.ts # Main module exports -โ”œโ”€โ”€ chatBoxComp.tsx # React component implementation -โ”œโ”€โ”€ yjs-websocket-server.js # WebSocket server for real-time sync -โ”œโ”€โ”€ yjs-websocket-server.cjs # CommonJS version of server -โ”‚ -โ”œโ”€โ”€ hooks/ -โ”‚ โ””โ”€โ”€ useChatManager.ts # Main React hook for chat functionality -โ”‚ -โ”œโ”€โ”€ managers/ -โ”‚ โ””โ”€โ”€ HybridChatManager.ts # Orchestrates local/collaborative providers -โ”‚ -โ”œโ”€โ”€ providers/ -โ”‚ โ”œโ”€โ”€ ChatDataProvider.ts # Abstract interface + base implementation -โ”‚ โ”œโ”€โ”€ ALASqlProvider.ts # Local storage with SQLite-like features -โ”‚ โ””โ”€โ”€ YjsPluvProvider.ts # Real-time collaboration with Yjs + WebSocket -โ”‚ -โ”œโ”€โ”€ types/ -โ”‚ โ””โ”€โ”€ chatDataTypes.ts # TypeScript definitions and utilities -โ”‚ -โ””โ”€โ”€ server/ # (Optional) Advanced server configurations -``` - -### **Component Hierarchy** - -``` -ChatBoxComp (Main React Component) - โ”‚ - โ”œโ”€โ”€ useChatManager (Hook) - โ”‚ โ”‚ - โ”‚ โ””โ”€โ”€ HybridChatManager (Manager) - โ”‚ โ”‚ - โ”‚ โ”œโ”€โ”€ ALASqlProvider (Local) - โ”‚ โ””โ”€โ”€ YjsPluvProvider (Collaborative) - โ”‚ - โ”œโ”€โ”€ Chat UI Components - โ”‚ โ”œโ”€โ”€ Message List - โ”‚ โ”œโ”€โ”€ Input Area - โ”‚ โ”œโ”€โ”€ Room Sidebar - โ”‚ โ””โ”€โ”€ User Management - โ”‚ - โ””โ”€โ”€ WebSocket Server (External) - โ””โ”€โ”€ yjs-websocket-server.js -``` - ---- - -## โœ… **Features Implemented** - -### **Core Chat Features** -- โœ… **Message Exchange**: Send/receive text messages in real-time -- โœ… **User Management**: Multiple users with unique IDs and display names -- โœ… **Typing Indicators**: Live typing status across users -- โœ… **Message Persistence**: Messages survive page refreshes and app restarts -- โœ… **Cross-tab Synchronization**: Real-time sync between browser tabs - -### **Room Management System** -- โœ… **Dynamic Room Creation**: Users can create public/private rooms -- โœ… **Room Discovery**: Browse and join available rooms -- โœ… **Room Switching**: Seamlessly move between different chat rooms -- โœ… **Participant Tracking**: Live participant counts and user lists -- โœ… **Permission System**: Configurable room access controls - -### **Storage & Synchronization** -- โœ… **Local Mode**: ALASql-based local persistence (works offline) -- โœ… **Collaborative Mode**: Yjs CRDT + WebSocket real-time sync -- โœ… **Hybrid Mode**: Automatic fallback between collaborative and local -- โœ… **Cross-device Sync**: Real-time synchronization across devices/browsers - -### **Developer Experience** -- โœ… **Provider Architecture**: Clean abstraction for different storage backends -- โœ… **TypeScript Support**: Comprehensive type definitions -- โœ… **Error Handling**: Graceful degradation and error recovery -- โœ… **Debugging Tools**: Extensive console logging for troubleshooting -- โœ… **Memory Management**: Proper cleanup and resource management - ---- - -## ๐Ÿš€ **Development Setup** - -### **Prerequisites** - -```bash -# Required software -Node.js >= 16.0.0 -Yarn >= 1.22.0 (preferred over npm) -Lowcoder development environment -``` - -### **Installation & Setup** - -```bash -# 1. Navigate to the Lowcoder client directory -cd client/packages/lowcoder - -# 2. Install dependencies (if not already done) -yarn install - -# 3. Verify chatBoxComponent is integrated -# Check that the component is registered in: -# - src/comps/uiCompRegistry.ts -# - src/comps/index.tsx -``` - -### **Starting Development** - -```bash -# Terminal 1: Start the main development server -cd client/packages/lowcoder -yarn start - -# Terminal 2: Start WebSocket server for real-time features -cd client/packages/lowcoder -node yjs-websocket-server.js # ES modules (recommended) -# OR -node yjs-websocket-server.cjs # CommonJS (fallback) -``` - -### **Component Integration** - -The ChatBoxComponent is already integrated into Lowcoder. To use it: - -1. **In Lowcoder Editor**: Drag "ChatBox" component from the component panel -2. **Configure Properties**: - - **Mode**: `local`, `collaborative`, or `hybrid` - - **User ID**: Unique identifier for the user - - **User Name**: Display name for the user - - **Room ID**: Chat room identifier - - **Server URL**: WebSocket server URL (for collaborative mode) - ---- - -## ๐Ÿงช **Testing & Debugging** - -### **๐Ÿš€ Quick Testing Guide** - -#### **Step 1: Start the WebSocket Server** - -Choose either ES modules (.js) or CommonJS (.cjs) version: - -```bash -# Method 1: ES modules (recommended) -cd client/packages/lowcoder -node yjs-websocket-server.js - -# Method 2: CommonJS (alternative) -cd client/packages/lowcoder -node yjs-websocket-server.cjs -``` - -**Expected Output:** -``` -๐Ÿš€ Starting Yjs WebSocket Server... -๐Ÿ“ก Server will run on: ws://localhost:3001 -๐Ÿ”Œ WebSocket server created -โœ… Yjs WebSocket Server is running! -๐Ÿ“ก WebSocket endpoint: ws://localhost:3001 -๐Ÿฅ Health check: http://localhost:3001/health -``` - -#### **๐Ÿ”ฅ Step 2: Test Real-time Multi-Browser Synchronization** - -1. **First Browser Tab/Window:** - ``` - - Add ChatBox component - - Set Mode: "Collaborative (Real-time)" - - Set User ID: "alice_123" - - Set User Name: "Alice" - - Set Room ID: "test_room" - ``` - -2. **Second Browser Tab/Window (or different browser):** - ``` - - Add ChatBox component - - Set Mode: "Collaborative (Real-time)" - - Set User ID: "bob_456" - - Set User Name: "Bob" - - Set Room ID: "test_room" (SAME!) - ``` - -3. **Send Messages:** - ``` - - Alice sends: "Hello from Alice!" - - Bob sends: "Hi Alice, this is Bob!" - - Messages should appear INSTANTLY in both browsers - ``` - -#### **โœ… Expected Console Logs (Success Indicators):** - -**YjsPluvProvider Logs:** -``` -[YjsPluvProvider] ๐Ÿš€ CONNECT called with config: {mode: "collaborative", ...} -[YjsPluvProvider] ๐Ÿ“„ Creating new Y.Doc for room: test_room -[YjsPluvProvider] ๐Ÿ”— Creating WebSocket connection... -[YjsPluvProvider] ๐Ÿ“ก URL: ws://localhost:3001 -[YjsPluvProvider] ๐Ÿ  Room: test_room -[YjsPluvProvider] โœ… Created new Y.Doc and WebSocket provider -[YjsPluvProvider] ๐Ÿ“ก WebSocket status changed: connected -[YjsPluvProvider] โœ… WebSocket connected - real-time sync enabled! -[YjsPluvProvider] ๐Ÿ”„ Document sync status: synced -``` - -**Message Synchronization Logs:** -``` -[YjsPluvProvider] ๐Ÿ“ค SENDING MESSAGE: -[YjsPluvProvider] ๐Ÿ’ฌ Text: Hello from Alice! -[YjsPluvProvider] ๐Ÿ‘ค Author: Alice (alice_123) -[YjsPluvProvider] ๐Ÿ  Room: test_room -[YjsPluvProvider] โœ… MESSAGE STORED in Yjs map -[YjsPluvProvider] ๐Ÿ”” MESSAGES MAP CHANGED! -[YjsPluvProvider] ๐Ÿ†• NEW MESSAGE DETECTED: -[YjsPluvProvider] โœ… Notified subscribers for room: test_room -``` - -#### **๐Ÿงช Advanced Testing Scenarios** - -**Test 1: Multiple Devices/Browsers** -1. Open the app in **Chrome, Firefox, and Safari** -2. Use the **same Room ID** in all browsers -3. Send messages from each browser -4. Verify **instant synchronization** across ALL browsers - -**Test 2: Network Resilience** -1. **Disconnect WiFi** while chatting -2. **Send messages** (should queue locally) -3. **Reconnect WiFi** -4. Verify **messages sync** when connection restored - -**Test 3: Server Restart** -1. **Stop WebSocket server** (Ctrl+C) -2. **Send messages** (should work locally) -3. **Restart server** (`node yjs-websocket-server.js`) -4. Verify **automatic reconnection** and sync - -**Test 4: Multiple Rooms** -1. Open **4 browser tabs** -2. Tabs 1-2 use Room ID: "room_alpha" -3. Tabs 3-4 use Room ID: "room_beta" -4. Send messages in both rooms -5. Verify **room isolation** (messages only sync within same room) - -### **๐Ÿ” Debug Mode & Logging** - -```javascript -// Enable detailed logging by checking browser console -// All operations are logged with prefixes: - -// ๐ŸŸข YjsPluvProvider logs -[YjsPluvProvider] ๐Ÿš€ CONNECT called... -[YjsPluvProvider] ๐Ÿ“„ Creating new Y.Doc for room... -[YjsPluvProvider] ๐Ÿ”— Creating WebSocket connection... -[YjsPluvProvider] โœ… WebSocket connected... - -// ๐ŸŸก HybridChatManager logs -[HybridChatManager] ๐Ÿ  Creating room from request... -[HybridChatManager] ๐Ÿ” Getting available rooms... -[HybridChatManager] ๐Ÿšช User joining room... - -// ๐Ÿ”ต ALASqlProvider logs (fallback) -[ALASqlProvider] ๐Ÿ“ฆ Local storage operations... -``` - -### **๐Ÿ”ง Development Commands** - -```bash -# Navigate to working directory -cd client/packages/lowcoder - -# Start WebSocket server (choose one) -node yjs-websocket-server.js # ES modules -node yjs-websocket-server.cjs # CommonJS - -# Start development server (in separate terminal) -yarn start - -# Build for production -yarn build - -# Health check WebSocket server -curl http://localhost:3001/health -``` - -### **๐Ÿ› Common Issues & Solutions** - -**Problem: "Failed to setup Yjs connection"** -- **Solution**: Ensure WebSocket server is running on port 3001 -- Check firewall/antivirus settings -- Try using `.cjs` version if `.js` fails - -**Problem: Messages not syncing between tabs** -- **Solution**: Verify both tabs use **exactly the same Room ID** -- Check browser console for connection logs -- Ensure mode is set to "Collaborative" -- Restart WebSocket server - -**Problem: WebSocket connection fails** -- **Solution**: Check if port 3001 is available -- Try different port: `PORT=3002 node yjs-websocket-server.js` -- Update serverUrl in chat config to match - -**Problem: Import/Export errors** -- **Solution**: Ensure `y-websocket` package is installed: `yarn add y-websocket` -- Check Node.js version (requires Node 16+) -- Try deleting `node_modules` and running `yarn install` - -### **๐Ÿš‘ Health Checks** - -```bash -# Check WebSocket server health -curl http://localhost:3001/health - -# Expected response: -{ - "status": "healthy", - "uptime": "00:05:32", - "connections": 2, - "rooms": ["test_room", "general"] -} -``` - ---- - -## ๐Ÿ— **Architecture Deep Dive** - -### **๐Ÿ”„ Data Flow Architecture** - -#### **Real-time Synchronization Flow** -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Browser A โ”‚โ”€โ”€โ”€โ–ถโ”‚ WebSocket Serverโ”‚โ—€โ”€โ”€โ”€โ”‚ Browser B โ”‚ -โ”‚ YjsProvider โ”‚ โ”‚ (Port 3001) โ”‚ โ”‚ YjsProvider โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ โ”‚ โ”‚ - โ–ผ โ–ผ โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Yjs Y.Doc (A) โ”‚โ”€โ”€โ”€โ–ถโ”‚ Shared Y.Doc โ”‚โ—€โ”€โ”€โ”€โ”‚ Yjs Y.Doc (B) โ”‚ -โ”‚ โ€ข messages โ”‚ โ”‚ โ€ข messages โ”‚ โ”‚ โ€ข messages โ”‚ -โ”‚ โ€ข rooms โ”‚ โ”‚ โ€ข rooms โ”‚ โ”‚ โ€ข rooms โ”‚ -โ”‚ โ€ข presence โ”‚ โ”‚ โ€ข presence โ”‚ โ”‚ โ€ข presence โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ โ”‚ - โ–ผ โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ ChatBox UI (A) โ”‚ โ”‚ ChatBox UI (B) โ”‚ -โ”‚ (React) โ”‚ โ”‚ (React) โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -#### **Provider Switching Logic** -``` -HybridChatManager Decision Tree: - -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Component Initialization โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Mode == 'collaborative'? โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ YES โ”‚ NO - โ–ผ โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Try YjsProvider โ”‚ โ”‚ Use ALASqlProvider โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ WebSocket connection OK? โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ FAIL โ”‚ SUCCESS - โ–ผ โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Fallback to โ”‚ โ”‚ Use YjsProvider โ”‚ -โ”‚ ALASqlProvider โ”‚ โ”‚ (Real-time mode) โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -### **๐Ÿ“ฆ Data Models** - -#### **UnifiedMessage Interface** -```typescript -interface UnifiedMessage { - // Core identification - id: string; // Unique message ID - text: string; // Message content - timestamp: number; // Unix timestamp - - // User information - authorId: string; // User ID who sent message - authorName: string; // Display name of user - - // Room association - roomId: string; // Which room this message belongs to - - // Status tracking - status: 'sending' | 'sent' | 'failed' | 'synced'; - messageType: 'text' | 'file' | 'system' | 'action'; - - // Real-time collaboration metadata - yjsId?: string; // Yjs document reference - version?: number; // Conflict resolution version - metadata?: Record; // Extensible metadata -} -``` - -#### **UnifiedRoom Interface** -```typescript -interface UnifiedRoom { - // Core identification - id: string; // Unique room ID - name: string; // Display name - type: 'private' | 'public' | 'group'; - - // Participant management - participants: string[]; // Array of user IDs - admins: string[]; // Array of admin user IDs - creator: string; // User ID who created room - - // Room state - isActive: boolean; // Whether room is active - lastActivity: number; // Last message timestamp - createdAt: number; // Room creation time - updatedAt: number; // Last room update - - // Optional settings - description?: string; // Room description - maxParticipants?: number; // Participant limit -} -``` - -### **โš™๏ธ Provider Architecture** - -#### **ChatDataProvider Interface** -The `ChatDataProvider` interface ensures consistent API across different storage backends: - -```typescript -interface ChatDataProvider { - // Connection management - connect(config: ConnectionConfig): Promise>; - disconnect(): Promise>; - getConnectionState(): ConnectionState; - isConnected(): boolean; - - // Core operations - sendMessage(message: Omit): Promise>; - getMessages(roomId: string, limit?: number): Promise>; - createRoom(room: Omit): Promise>; - getRooms(userId?: string): Promise>; - - // Real-time subscriptions - subscribeToRoom(roomId: string, callback: (event: ChatEvent) => void): UnsubscribeFunction; - subscribeToPresence(roomId: string, callback: (users: UserPresence[]) => void): UnsubscribeFunction; - subscribeToTyping(roomId: string, callback: (typingUsers: TypingState[]) => void): UnsubscribeFunction; -} -``` - -#### **Provider Implementations** - -**ALASqlProvider (Local Storage)** -- **Purpose**: Offline-capable local storage using SQLite-like syntax -- **Features**: Persistence across browser sessions, cross-tab synchronization -- **Best for**: Offline mode, local development, fallback when network fails -- **Storage**: Browser IndexedDB via ALASql - -**YjsPluvProvider (Real-time Collaboration)** -- **Purpose**: Real-time multi-user synchronization using Yjs CRDTs -- **Features**: Conflict-free merge resolution, real-time presence, typing indicators -- **Best for**: Multi-user collaboration, real-time sync across devices -- **Storage**: In-memory with WebSocket server persistence - -### **๐Ÿš€ Performance Optimizations** - -#### **Memory Management** -- **Reference Counting**: Shared Yjs documents with automatic cleanup when unused -- **Observer Cleanup**: Proper event listener removal prevents memory leaks -- **Connection Pooling**: Reuse WebSocket connections across component instances -- **Subscription Tracking**: Automatic cleanup of event subscriptions on unmount - -#### **Network Optimizations** -- **Connection Persistence**: WebSocket connections survive component re-renders -- **Automatic Reconnection**: Smart retry logic with exponential backoff -- **Fallback Handling**: Seamless switch to local mode when server unavailable -- **Batch Operations**: Minimize WebSocket message frequency - -#### **UI Performance** -- **Message Virtualization**: Efficient rendering of large message lists -- **Optimistic Updates**: Immediate UI updates with server reconciliation -- **Debounced Typing**: Reduce typing indicator network traffic -- **State Normalization**: Efficient React re-rendering patterns - ---- - -## ๐Ÿ“š **API Reference** - -### **useChatManager Hook** - -Main React hook for chat functionality: - -```typescript -const { - // Connection state - isConnected, - connectionState, - - // Core chat operations - sendMessage, - messages, - - // Room management - currentRoom, - joinedRooms, - createRoom, - joinRoom, - leaveRoom, - - // Real-time features - onlineUsers, - typingUsers, - startTyping, - stopTyping, - - // Lifecycle - connect, - disconnect -} = useChatManager({ - userId: 'user_123', - userName: 'John Doe', - applicationId: 'my_app', - roomId: 'general', - mode: 'collaborative', // 'local' | 'collaborative' | 'hybrid' - autoConnect: true -}); -``` - -### **Component Properties** - -```typescript -interface ChatBoxProps { - // User configuration - userId: string; // Unique user identifier - userName: string; // Display name for user - applicationId: string; // App identifier for data isolation - - // Room configuration - roomId: string; // Initial room to join - mode: 'local' | 'collaborative' | 'hybrid'; - - // Server configuration (for collaborative mode) - serverUrl?: string; // WebSocket server URL - - // UI configuration - autoHeight: boolean; // Adjust height automatically - showTypingIndicators: boolean; // Display typing indicators - showOnlineUsers: boolean; // Display online user list - - // Room management - allowRoomCreation: boolean; // Enable room creation - allowRoomJoining: boolean; // Enable room joining - showAvailableRooms: boolean; // Show room browser - maxRoomsDisplay: number; // Limit room list size - - // Event handlers - onEvent: (event: EventType) => void; -} -``` - -### **Error Handling** - -```typescript -// All operations return OperationResult -interface OperationResult { - success: boolean; - data?: T; - error?: string; - timestamp: number; -} - -// Usage example -const result = await sendMessage('Hello world!'); -if (!result.success) { - console.error('Failed to send message:', result.error); - // Handle error appropriately -} -``` - -### **Usage Examples** - -#### **Basic Local Chat** -```typescript -// Simple local chat setup - -``` - -#### **Real-time Collaborative Chat** -```typescript -// Real-time collaborative chat with room management - -``` - -#### **Hybrid Mode with Fallback** -```typescript -// Hybrid mode - tries collaborative, falls back to local - -``` - ---- - -## ๐Ÿ”ฎ **Future Enhancements** - -### **Planned Features** - -#### **Short-term (Next Sprint)** -- ๐Ÿ”ด **File Attachments**: Support for images, documents, and media -- ๐Ÿ”ด **Message Reactions**: Emoji reactions and message threading -- ๐Ÿ”ด **Message Search**: Full-text search across message history -- ๐Ÿ”ด **User Mentions**: @mention functionality with notifications - -#### **Medium-term (Next Quarter)** -- ๐ŸŸก **Voice Messages**: Audio recording and playback -- ๐ŸŸก **Video Chat Integration**: WebRTC peer-to-peer video calls -- ๐ŸŸก **Message Encryption**: End-to-end encryption for private rooms -- ๐ŸŸก **Push Notifications**: Browser notifications for new messages - -#### **Long-term (Future Releases)** -- ๐ŸŸข **AI Integration**: Smart suggestions and chatbot support -- ๐ŸŸข **Advanced Moderation**: Automated content filtering and user moderation -- ๐ŸŸข **Analytics Dashboard**: Usage metrics and chat analytics -- ๐ŸŸข **Mobile SDK**: React Native component for mobile apps - -### **Technical Debt & Improvements** - -#### **Performance** -- **Message Pagination**: Implement virtual scrolling for large chat histories -- **Image Optimization**: Automatic image compression and lazy loading -- **Bundle Optimization**: Reduce component bundle size with code splitting - -#### **Developer Experience** -- **Storybook Integration**: Interactive component documentation -- **Unit Test Coverage**: Increase test coverage to 90%+ -- **E2E Testing**: Automated browser testing for multi-user scenarios -- **Performance Monitoring**: Real-time performance metrics and alerting - ---- - -## โš ๏ธ **Known Issues & Limitations** - -### **Current Limitations** - -1. **File Attachments**: Not yet implemented - text messages only -2. **Message History**: Limited to 1000 messages per room (configurable) -3. **User Presence**: Basic online/offline - no rich presence status -4. **Mobile Support**: Optimized for desktop, mobile experience needs improvement -5. **Scalability**: WebSocket server not production-ready (single instance) - -### **Browser Compatibility** - -| Browser | Status | Notes | -|---------|--------| ----- | -| Chrome 90+ | โœ… Full Support | Recommended browser | -| Firefox 85+ | โœ… Full Support | All features working | -| Safari 14+ | โœ… Full Support | WebSocket limitations on iOS | -| Edge 90+ | โœ… Full Support | Chromium-based versions | -| IE 11 | โŒ Not Supported | Missing WebSocket and ES6 features | - -### **Production Considerations** - -1. **WebSocket Server**: Current server is for development only - - **Solution**: Deploy production-grade WebSocket infrastructure - - **Alternatives**: Consider Socket.io, Pusher, or Ably for production - -2. **Data Persistence**: Yjs server doesn't persist data between restarts - - **Solution**: Implement Redis or database backend for persistence - -3. **Authentication**: No built-in authentication mechanism - - **Solution**: Integrate with your app's authentication system - -4. **Rate Limiting**: No protection against message spam - - **Solution**: Implement server-side rate limiting - ---- - -## ๐Ÿค **Contributing Guidelines** - -### **Development Workflow** - -```bash -# 1. Create feature branch -git checkout -b feature/message-reactions - -# 2. Make incremental changes -# - Follow small, testable implementations -# - Update types in chatDataTypes.ts first -# - Implement in providers -# - Update HybridChatManager -# - Add UI components -# - Update tests - -# 3. Test thoroughly -yarn test -yarn build - -# 4. Test real-time features -node yjs-websocket-server.js -# Test in multiple browsers - -# 5. Update documentation -# - Update this README.md -# - Add inline code comments -# - Update API documentation -``` - -### **Code Standards** - -#### **TypeScript Guidelines** -- **Strict Types**: Always use proper TypeScript types, avoid `any` -- **Interface First**: Define interfaces before implementation -- **Error Handling**: Use `OperationResult` for all async operations -- **Null Safety**: Handle null/undefined cases explicitly - -#### **React Best Practices** -- **Hooks**: Prefer hooks over class components -- **Memoization**: Use `useMemo`/`useCallback` for expensive operations -- **State Management**: Keep state as local as possible -- **Error Boundaries**: Implement error boundaries for chat components - -#### **Testing Requirements** -- **Unit Tests**: Test individual functions and providers -- **Integration Tests**: Test provider interactions and data flow -- **E2E Tests**: Test real-time synchronization across browsers -- **Performance Tests**: Measure memory usage and WebSocket efficiency - -### **Architecture Decisions** - -#### **Provider Pattern** -The provider pattern allows easy switching between storage backends: -- **Benefits**: Clean abstraction, testability, extensibility -- **Trade-offs**: Additional complexity, potential over-engineering -- **Alternatives**: Direct implementation without abstraction - -#### **Yjs for Real-time Sync** -Yjs provides conflict-free replicated data types (CRDTs): -- **Benefits**: Automatic conflict resolution, offline support, mature library -- **Trade-offs**: Learning curve, bundle size, WebSocket dependency -- **Alternatives**: Socket.io with manual conflict resolution, OT algorithms - -#### **Hybrid Manager Approach** -HybridChatManager coordinates multiple providers: -- **Benefits**: Graceful degradation, mode switching, unified API -- **Trade-offs**: Additional complexity, potential sync issues -- **Alternatives**: Single provider with mode configuration - -### **Performance Guidelines** - -1. **Memory Management**: Always clean up subscriptions and observers -2. **Network Efficiency**: Batch operations when possible -3. **UI Responsiveness**: Use virtualization for large message lists -4. **Error Recovery**: Implement reconnection and retry logic - -### **How Things Are Connected** - -#### **Data Flow Overview** -``` -User Action (Send Message) - โ†“ -ChatBoxComponent (React UI) - โ†“ -useChatManager Hook - โ†“ -HybridChatManager - โ†“ -Active Provider (ALASql OR YjsPluvProvider) - โ†“ -Storage Layer (IndexedDB OR WebSocket + Yjs) - โ†“ -Real-time Updates - โ†“ -Observer Callbacks - โ†“ -React State Updates - โ†“ -UI Re-render with New Message -``` - -#### **Component Integration Points** -1. **Component Registration**: `src/comps/uiCompRegistry.ts` and `src/comps/index.tsx` -2. **Event System**: Uses Lowcoder's event handling system for user interactions -3. **Styling**: Integrates with Lowcoder's design system and theming -4. **State Management**: Uses React hooks with proper cleanup - -#### **Real-time Synchronization Chain** -1. **Message Sent**: User types and sends message -2. **Local Update**: Immediate UI update (optimistic) -3. **Provider Storage**: Message stored in active provider -4. **WebSocket Broadcast**: If collaborative mode, sent to server -5. **Remote Updates**: Other clients receive via WebSocket -6. **Yjs Integration**: CRDT merge resolution if conflicts -7. **Observer Triggers**: Yjs observers fire for remote changes -8. **State Sync**: React state updated with remote messages -9. **UI Update**: New messages appear in other browsers - ---- - -## ๐ŸŽ‰ **Success Metrics - ACHIEVED โœ…** - -- [x] **Real-time synchronization** across multiple browsers/devices -- [x] **WebSocket connection** with robust error handling -- [x] **Message persistence** with local and collaborative storage -- [x] **Room management** with dynamic creation and joining -- [x] **Typing indicators** and user presence tracking -- [x] **Provider architecture** with clean abstraction layers -- [x] **Memory management** with proper cleanup and reference counting -- [x] **Cross-browser compatibility** tested on major browsers -- [x] **Developer experience** with comprehensive TypeScript support -- [x] **Production readiness** with error handling and fallback mechanisms - ---- - -## ๐Ÿ“– **What's Done and What's Remaining** - -### **โœ… COMPLETED FEATURES** - -#### **Core Architecture (100% Complete)** -- **Provider Pattern**: Clean abstraction layer for different storage backends -- **HybridChatManager**: Intelligent provider coordination and fallback -- **TypeScript Integration**: Full type safety and interface definitions -- **Error Handling**: Comprehensive error recovery and user feedback - -#### **Local Storage (100% Complete)** -- **ALASqlProvider**: SQLite-like local persistence -- **Cross-tab Sync**: Shared data between browser tabs -- **Offline Support**: Works without network connection -- **Data Persistence**: Survives browser restarts - -#### **Real-time Collaboration (100% Complete)** -- **YjsPluvProvider**: CRDT-based real-time synchronization -- **WebSocket Server**: Functional server for development -- **Multi-browser Sync**: Real-time updates across devices -- **Presence System**: User online status and typing indicators -- **Memory Management**: Proper cleanup and reference counting - -#### **Room Management (100% Complete)** -- **Dynamic Room Creation**: Users can create new rooms -- **Room Discovery**: Browse and join available rooms -- **Permission System**: Configurable access controls -- **Participant Tracking**: Live user counts and lists - -#### **UI Components (100% Complete)** -- **Message Interface**: Clean, responsive chat UI -- **Room Sidebar**: Room navigation and management -- **Typing Indicators**: Live typing status display -- **User Management**: Online user lists and presence - -### **๐Ÿ”„ WHAT'S REMAINING (Future Enhancements)** - -#### **Feature Enhancements (Not Critical)** -- **File Attachments**: Image and document sharing -- **Message Reactions**: Emoji reactions and threading -- **Voice Messages**: Audio recording capabilities -- **Video Integration**: WebRTC video calling -- **Message Search**: Full-text search functionality - -#### **Production Hardening (Environment-Specific)** -- **Production WebSocket Server**: Scalable server infrastructure -- **Authentication Integration**: Connect to existing auth systems -- **Rate Limiting**: Anti-spam protection -- **Data Persistence**: Server-side message storage -- **Performance Monitoring**: Real-time metrics and alerting - -#### **Developer Tools (Nice-to-Have)** -- **Storybook Documentation**: Interactive component docs -- **Automated Testing**: Comprehensive test suite -- **Performance Profiling**: Memory and network monitoring -- **Mobile Optimization**: Enhanced mobile experience - -### **๐ŸŽฏ CURRENT STATUS: PRODUCTION READY** - -The ChatBoxComponent is **fully functional and ready for production use** in Lowcoder applications. All core features are implemented and tested: - -- โœ… **Real-time messaging** works across multiple browsers -- โœ… **Local persistence** maintains data integrity -- โœ… **Room management** provides full multi-room support -- โœ… **Error handling** ensures graceful degradation -- โœ… **Developer experience** includes comprehensive documentation - -The remaining items are **enhancements** rather than requirements, making this component suitable for immediate integration into production Lowcoder environments. - ---- - -**Status**: โœ… **PRODUCTION READY** - -The ChatBoxComponent provides a complete real-time chat solution with local persistence, collaborative synchronization, dynamic room management, and comprehensive developer tooling. Ready for integration into production Lowcoder applications. diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatBoxComp.tsx deleted file mode 100644 index 013f545577..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatBoxComp.tsx +++ /dev/null @@ -1,1449 +0,0 @@ -import { ScrollBar, Section, sectionNames } from "lowcoder-design"; -import styled, { css } from "styled-components"; -import { UICompBuilder } from "../../generators"; -import { NameConfig, NameConfigHidden, withExposingConfigs } from "../../generators/withExposing"; -import { withMethodExposing } from "../../generators/withMethodExposing"; -import { TextStyle, TextStyleType, AnimationStyle, AnimationStyleType } from "comps/controls/styleControlConstants"; -import { hiddenPropertyView } from "comps/utils/propertyUtils"; -import React, { useContext, useEffect, useRef, useMemo, useState } from "react"; -import { EditorContext } from "comps/editorState"; -import { ToViewReturn } from "../../generators/multi"; -import { useCompClickEventHandler } from "../../utils/useCompClickEventHandler"; -import { Button, Input, Modal, Form, Radio, Space, Typography, Divider, Badge, Tooltip, Popconfirm } from "antd"; -import { PlusOutlined, SearchOutlined, GlobalOutlined, LockOutlined, UserOutlined, CheckCircleOutlined, LogoutOutlined } from "@ant-design/icons"; -import { useChatManager } from "./hooks/useChatManager"; -import { UnifiedMessage } from "./types/chatDataTypes"; -import { chatCompChildrenMap, ChatCompChildrenType } from "./chatUtils"; - -// // Event options for the chat component -// const EventOptions = [clickEvent, doubleClickEvent] as const; - -// Chat component styling -const ChatContainer = styled.div<{ - $styleConfig: TextStyleType; - $animationStyle: AnimationStyleType; -}>` - height: 100%; - display: flex; - overflow: hidden; - border-radius: ${(props) => props.$styleConfig.radius || "4px"}; - border: ${(props) => props.$styleConfig.borderWidth || "1px"} solid ${(props) => props.$styleConfig.border || "#e0e0e0"}; - background: ${(props) => props.$styleConfig.background || "#ffffff"}; - font-family: ${(props) => props.$styleConfig.fontFamily || "Inter, sans-serif"}; - ${(props) => props.$animationStyle} -`; - -const LeftPanel = styled.div<{ $width: string }>` - width: ${(props) => props.$width}; - border-right: 1px solid #f0f0f0; - display: flex; - flex-direction: column; - background: #fafbfc; - position: relative; - - &::before { - content: ''; - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 1px; - background: linear-gradient(180deg, transparent 0%, #e6f7ff 50%, transparent 100%); - opacity: 0.5; - } -`; - -const RightPanel = styled.div` - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; -`; - -const ChatHeader = styled.div<{ $styleConfig: TextStyleType }>` - padding: 16px; - border-bottom: 1px solid #e0e0e0; - background: ${(props) => props.$styleConfig.background || "#ffffff"}; - font-size: ${(props) => props.$styleConfig.textSize || "16px"}; - font-weight: ${(props) => props.$styleConfig.textWeight || "600"}; - color: ${(props) => props.$styleConfig.text || "#1a1a1a"}; -`; - -const RoomsSection = styled.div` - flex: 1; - overflow-y: auto; - margin-bottom: 8px; - padding: 0 8px; -`; - -const RoomItem = styled.div<{ $isActive?: boolean; $styleConfig: TextStyleType }>` - padding: 10px 12px; - margin-bottom: 6px; - border-radius: 8px; - cursor: pointer; - background: ${(props) => props.$isActive ? props.$styleConfig.links || "#1890ff" : "#ffffff"}; - color: ${(props) => props.$isActive ? "#ffffff" : props.$styleConfig.text || "#262626"}; - font-size: ${(props) => props.$styleConfig.textSize || "13px"}; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - border: 1px solid ${(props) => props.$isActive ? "transparent" : "#f0f0f0"}; - box-shadow: ${(props) => props.$isActive ? "0 3px 8px rgba(24, 144, 255, 0.15)" : "0 1px 2px rgba(0, 0, 0, 0.04)"}; - position: relative; - overflow: hidden; - - &:hover { - background: ${(props) => props.$isActive ? props.$styleConfig.links || "#1890ff" : "#fafafa"}; - transform: translateY(-1px); - box-shadow: ${(props) => props.$isActive ? "0 4px 10px rgba(24, 144, 255, 0.2)" : "0 3px 8px rgba(0, 0, 0, 0.1)"}; - border-color: ${(props) => props.$isActive ? "transparent" : "#d9d9d9"}; - } - - &:active { - transform: translateY(0); - } -`; - -const ChatArea = styled.div` - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; -`; - -const MessageBubble = styled.div<{ $isOwn: boolean; $styleConfig: TextStyleType }>` - max-width: 70%; - padding: 12px 16px; - border-radius: 18px; - align-self: ${(props) => props.$isOwn ? "flex-end" : "flex-start"}; - background: ${(props) => props.$isOwn ? (props.$styleConfig.links || "#007bff") : "#f1f3f4"}; - color: ${(props) => props.$isOwn ? "#ffffff" : (props.$styleConfig.text || "#333")}; - font-size: ${(props) => props.$styleConfig.textSize || "14px"}; - word-wrap: break-word; -`; - -const MessageInput = styled.div` - padding: 16px; - border-top: 1px solid #e0e0e0; - display: flex; - gap: 8px; - align-items: center; -`; - -const InputField = styled.textarea<{ $styleConfig: TextStyleType }>` - flex: 1; - padding: 12px 16px; - border: 1px solid #e0e0e0; - border-radius: 20px; - resize: none; - max-height: 100px; - min-height: 40px; - font-family: ${(props) => props.$styleConfig.fontFamily || "Inter, sans-serif"}; - font-size: ${(props) => props.$styleConfig.textSize || "14px"}; - color: ${(props) => props.$styleConfig.text || "#333"}; - outline: none; - - &:focus { - border-color: ${(props) => props.$styleConfig.links || "#007bff"}; - } -`; - -const SendButton = styled.button<{ $styleConfig: TextStyleType }>` - padding: 8px 16px; - background: ${(props) => props.$styleConfig.links || "#007bff"}; - color: #ffffff; - border: none; - border-radius: 20px; - cursor: pointer; - font-size: ${(props) => props.$styleConfig.textSize || "14px"}; - font-weight: 500; - transition: background-color 0.2s; - - &:hover { - opacity: 0.9; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`; - -const EmptyState = styled.div<{ $styleConfig: TextStyleType }>` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - color: ${(props) => props.$styleConfig.text || "#666"}; - font-size: ${(props) => props.$styleConfig.textSize || "14px"}; - text-align: center; - gap: 8px; -`; - -const ConnectionStatus = styled.div<{ $connected: boolean; $styleConfig: TextStyleType }>` - padding: 8px 16px; - background: ${(props) => props.$connected ? "#d4edda" : "#f8d7da"}; - color: ${(props) => props.$connected ? "#155724" : "#721c24"}; - font-size: 12px; - text-align: center; - border-bottom: 1px solid #e0e0e0; -`; - -const TypingIndicator = styled.div<{ $styleConfig: TextStyleType }>` - padding: 8px 16px; - font-size: 12px; - color: #666; - font-style: italic; - opacity: 0.8; - border-bottom: 1px solid #e0e0e0; - background: #f9f9f9; - - .typing-dots { - display: inline-block; - margin-left: 8px; - } - - .typing-dots span { - display: inline-block; - background-color: #bbb; - border-radius: 50%; - width: 4px; - height: 4px; - margin: 0 1px; - animation: typing 1.4s infinite ease-in-out both; - } - - .typing-dots span:nth-child(1) { animation-delay: -0.32s; } - .typing-dots span:nth-child(2) { animation-delay: -0.16s; } - .typing-dots span:nth-child(3) { animation-delay: 0s; } - - @keyframes typing { - 0%, 80%, 100% { - transform: scale(0); - opacity: 0.3; - } - 40% { - transform: scale(1); - opacity: 1; - } - } -`; - -// Property view component -const ChatPropertyView = React.memo((props: { - children: ChatCompChildrenType -}) => { - const editorContext = useContext(EditorContext); - const editorModeStatus = useMemo(() => editorContext.editorModeStatus, [editorContext.editorModeStatus]); - - const basicSection = useMemo(() => ( -
- {props.children.chatName.propertyView({ - label: "Chat Name", - tooltip: "Name displayed in the chat header" - })} - {props.children.userId.propertyView({ - label: "User ID", - tooltip: "Unique identifier for the current user" - })} - {props.children.userName.propertyView({ - label: "User Name", - tooltip: "Display name for the current user" - })} - {props.children.applicationId.propertyView({ - label: "Application ID", - tooltip: "Unique identifier for this Lowcoder application - all chat components with the same Application ID can discover each other's rooms" - })} - {props.children.roomId.propertyView({ - label: "Initial Room", - tooltip: "Default room to join when the component loads (within the application scope)" - })} - {props.children.mode.propertyView({ - label: "Sync Mode", - tooltip: "Choose how messages are synchronized: Collaborative (real-time), Hybrid (local + real-time), or Local only" - })} -
- ), [props.children]); - - const roomManagementSection = useMemo(() => ( -
- {props.children.allowRoomCreation.propertyView({ - label: "Allow Room Creation", - tooltip: "Allow users to create new chat rooms" - })} - {props.children.allowRoomJoining.propertyView({ - label: "Allow Room Joining", - tooltip: "Allow users to join existing rooms" - })} - {props.children.roomPermissionMode.propertyView({ - label: "Permission Mode", - tooltip: "Control how users can join rooms" - })} - {props.children.showAvailableRooms.propertyView({ - label: "Show Available Rooms", - tooltip: "Display list of available rooms to join" - })} - {props.children.maxRoomsDisplay.propertyView({ - label: "Max Rooms to Display", - tooltip: "Maximum number of rooms to show in the list" - })} -
- ), [props.children]); - - const interactionSection = useMemo(() => - ["logic", "both"].includes(editorModeStatus) && ( -
- {hiddenPropertyView(props.children)} - {props.children.onEvent.getPropertyView()} -
- ), [editorModeStatus, props.children]); - - const layoutSection = useMemo(() => - ["layout", "both"].includes(editorModeStatus) && ( - <> -
- {props.children.autoHeight.getPropertyView()} - {props.children.leftPanelWidth.propertyView({ - label: "Left Panel Width", - tooltip: "Width of the rooms/people panel (e.g., 300px, 25%)" - })} - {props.children.showRooms.propertyView({ - label: "Show Rooms" - })} -
-
- {props.children.style.getPropertyView()} -
-
- {props.children.animationStyle.getPropertyView()} -
- - ), [editorModeStatus, props.children]); - - return ( - <> - {basicSection} - {roomManagementSection} - {interactionSection} - {layoutSection} - - ); -}); - -// Handler for joinUser method -const handleJoinUser = async ( - comp: any, - userId: string, - userName: string, -) => { - try { - // Update the component's internal state with user credentials - comp.children.userId.getView().onChange(userId); - comp.children.userName.getView().onChange(userName); - - console.log('[ChatBox] ๐Ÿ‘ค User joined as:', { userId, userName }); - - // The chat manager will automatically reconnect with new credentials - // due to the useEffect that watches for userId/userName changes - return true; - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error joining as user:', error); - return false; - } -}; - -// Main view component -const ChatBoxView = React.memo((props: ToViewReturn) => { - const [currentMessage, setCurrentMessage] = useState(""); - const [joinedRooms, setJoinedRooms] = useState([]); - const [searchableRooms, setSearchableRooms] = useState([]); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [showSearchResults, setShowSearchResults] = useState(false); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [createRoomForm] = Form.useForm(); - const handleClickEvent = useCompClickEventHandler({onEvent: props.onEvent}); - const chatAreaRef = useRef(null); - const searchTimeoutRef = useRef(null); - - // Helper function to trigger custom events - const triggerEvent = (eventName: string) => { - if (props.onEvent) { - props.onEvent(eventName); - } - }; - - // Initialize chat manager - const modeValue = props.mode as 'local' | 'collaborative' | 'hybrid'; - - // Only auto-connect if userId and userName are provided in configuration - const shouldAutoConnect = !!(props.userId.value && props.userName.value); - - const chatManager = useChatManager({ - userId: props.userId.value, - userName: props.userName.value, - applicationId: props.applicationId.value || "lowcoder_app", - roomId: props.roomId.value || "general", - mode: modeValue, // Use mode from props - autoConnect: shouldAutoConnect, // Only auto-connect if credentials are provided - }); - - // Handle reconnection when userId or userName changes (for public users) - useEffect(() => { - if (props.userId.value && props.userName.value) { - if (chatManager.isConnected) { - // Disconnect and let the chat manager reconnect with new credentials - chatManager.disconnect().then(() => { - console.log('[ChatBox] ๐Ÿ”„ Reconnecting with new user credentials'); - }); - } else { - // If not connected and we have credentials, trigger connection - console.log('[ChatBox] ๐Ÿ”Œ Connecting with user credentials'); - } - } - }, [props.userId.value, props.userName.value]); - - // Chat event handlers - useEffect(() => { - if (chatManager.isConnected) { - triggerEvent("connected"); - } else if (chatManager.error) { - triggerEvent("error"); - } - }, [chatManager.isConnected, chatManager.error]); - - // Load joined rooms when connected - useEffect(() => { - const loadRooms = async () => { - if (chatManager.isConnected) { - try { - console.log('[ChatBox] ๐Ÿ”„ Loading joined rooms...'); - const allRooms = await chatManager.getAvailableRooms(); - console.log('[ChatBox] ๐Ÿ” getAvailableRooms result:', allRooms); - - if (!allRooms || !Array.isArray(allRooms)) { - console.warn('[ChatBox] โš ๏ธ getAvailableRooms returned undefined or invalid data:', allRooms); - // Keep existing joined rooms if API fails - return; - } - - // Filter to only show rooms the user is a member of - // Participants can be either strings (user IDs) or objects with id property - const userJoinedRooms = allRooms.filter((room: any) => { - if (!room.participants) { - console.log(`[ChatBox] ๐Ÿ” Room "${room.name}" has no participants - excluding`); - return false; - } - - console.log(`[ChatBox] ๐Ÿ” Checking room "${room.name}" participants:`, room.participants, 'vs current user:', props.userId.value); - console.log(`[ChatBox] ๐Ÿ” Current userName: "${props.userName.value}"`); - - const isUserInRoom = room.participants.some((p: any) => { - // Handle both string participants (just user IDs) and object participants - const participantId = typeof p === 'string' ? p : p.id; - const isMatch = participantId === props.userId.value; - console.log(`[ChatBox] ๐Ÿ” Participant ${participantId} === ${props.userId.value}? ${isMatch}`); - return isMatch; - }); - - console.log(`[ChatBox] ๐Ÿ” Room "${room.name}" - User is ${isUserInRoom ? 'MEMBER' : 'NOT MEMBER'}`); - return isUserInRoom; - }); - console.log('[ChatBox] ๐Ÿ“‹ Found joined rooms:', userJoinedRooms.map((r: any) => r.name)); - setJoinedRooms(userJoinedRooms); - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Failed to load joined rooms:', error); - } - } - }; - - loadRooms(); - }, [chatManager.isConnected, props.userId.value, chatManager.getAvailableRooms]); - - // Refresh joined rooms periodically - useEffect(() => { - if (!chatManager.isConnected) return; - - const refreshInterval = setInterval(async () => { - try { - console.log('[ChatBox] ๐Ÿ”„ Refreshing joined rooms...'); - const allRooms = await chatManager.getAvailableRooms(); - console.log('[ChatBox] ๐Ÿ” Refresh getAvailableRooms result:', allRooms); - - if (!allRooms || !Array.isArray(allRooms)) { - console.warn('[ChatBox] โš ๏ธ Refresh getAvailableRooms returned undefined or invalid data:', allRooms); - // Skip this refresh cycle if data is invalid - return; - } - - const userJoinedRooms = allRooms.filter((room: any) => { - if (!room.participants) return false; - - return room.participants.some((p: any) => { - // Handle both string participants (just user IDs) and object participants - const participantId = typeof p === 'string' ? p : p.id; - return participantId === props.userId.value; - }); - }); - setJoinedRooms(userJoinedRooms); - console.log('[ChatBox] ๐Ÿ“‹ Refreshed joined rooms count:', userJoinedRooms.length); - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Failed to refresh joined rooms:', error); - } - }, 5000); // Refresh every 5 seconds - - return () => clearInterval(refreshInterval); - }, [chatManager.isConnected, props.userId.value, chatManager.getAvailableRooms]); - - // Room management functions - const handleCreateRoom = async (values: any) => { - try { - const newRoom = await chatManager.createRoomFromRequest({ - name: values.roomName.trim(), - type: values.roomType, - description: values.description || `Created by ${props.userName.value}` - }); - - if (newRoom) { - console.log('[ChatBox] โœ… Created room:', newRoom.name); - - // Automatically join the room as the creator - const joinSuccess = await chatManager.joinRoom(newRoom.id); - - // Always add the room to joined rooms regardless of join success - // This ensures the UI works even if there are backend sync issues - const roomWithUser = { - ...newRoom, - participants: [ - ...(newRoom.participants || []), - { id: props.userId.value, name: props.userName.value } - ] - }; - - // Add to joined rooms immediately - setJoinedRooms(prev => [...prev, roomWithUser]); - - if (joinSuccess) { - console.log('[ChatBox] โœ… Creator automatically joined the room'); - console.log('[ChatBox] ๐Ÿ“‹ Created room added to joined rooms and set as active'); - } else { - console.warn('[ChatBox] โš ๏ธ Failed to auto-join created room, but room added to local state'); - } - - // Reset form and close modal - createRoomForm.resetFields(); - setIsCreateModalOpen(false); - } - } catch (error) { - console.error('Failed to create room:', error); - } - }; - - const handleJoinRoom = async (roomId: string) => { - try { - console.log('[ChatBox] ๐Ÿšช Attempting to join room:', roomId); - const success = await chatManager.joinRoom(roomId); - if (success) { - console.log('[ChatBox] โœ… Successfully joined room:', roomId); - - // Find the room from search results - const roomToAdd = searchResults.find((room: any) => room.id === roomId); - if (roomToAdd) { - // Add current user to participants for immediate local state update - const roomWithUser = { - ...roomToAdd, - participants: [ - ...(roomToAdd.participants || []), - { id: props.userId.value, name: props.userName.value } - ] - }; - - // Add to joined rooms immediately - setJoinedRooms(prev => [...prev, roomWithUser]); - console.log('[ChatBox] ๐Ÿ“‹ Added room to joined rooms locally'); - } - - // Remove the joined room from search results - setSearchResults(prev => prev.filter((room: any) => room.id !== roomId)); - - // Clear search state to show joined rooms - setSearchQuery(""); - setShowSearchResults(false); - - // Trigger room joined event - triggerEvent("roomJoined"); - - console.log('[ChatBox] ๐Ÿ“‹ Room join completed successfully'); - } else { - console.log('[ChatBox] โŒ Failed to join room:', roomId); - } - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error joining room:', error); - } - }; - - const handleLeaveRoom = async (roomId: string) => { - try { - console.log('[ChatBox] ๐Ÿšช Attempting to leave room:', roomId); - const success = await chatManager.leaveRoom(roomId); - if (success) { - console.log('[ChatBox] โœ… Successfully left room:', roomId); - - // Remove the room from joined rooms immediately - const updatedJoinedRooms = joinedRooms.filter((room: any) => room.id !== roomId); - setJoinedRooms(updatedJoinedRooms); - - // Trigger room left event - triggerEvent("roomLeft"); - - // If user left the current room, switch to another joined room or clear chat - if (currentRoom?.id === roomId) { - if (updatedJoinedRooms.length > 0) { - await chatManager.joinRoom(updatedJoinedRooms[0].id); - } else { - // No more rooms joined, user needs to search and join a room - console.log('[ChatBox] โ„น๏ธ No more joined rooms, user needs to search for rooms'); - } - } - } else { - console.log('[ChatBox] โŒ Failed to leave room:', roomId); - } - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error leaving room:', error); - } - }; - - // Search functionality - searches all available rooms, not just joined ones - const handleSearch = async (query: string) => { - if (!query.trim()) { - setShowSearchResults(false); - setSearchResults([]); - return; - } - - setIsSearching(true); - try { - console.log('[ChatBox] ๐Ÿ” Searching for rooms:', query); - - // Get all available rooms and filter by search query - const allRooms = await chatManager.getAvailableRooms(); - console.log('[ChatBox] ๐Ÿ” Search getAvailableRooms result:', allRooms); - - if (!allRooms || !Array.isArray(allRooms)) { - console.warn('[ChatBox] โš ๏ธ Search getAvailableRooms returned undefined or invalid data:', allRooms); - setSearchResults([]); - setShowSearchResults(true); - return; - } - - console.log('[ChatBox] ๐Ÿ” All available rooms count:', allRooms.length); - console.log('[ChatBox] ๐Ÿ” User ID for filtering:', props.userId.value); - - // Show all public rooms that match search, regardless of current membership - const filtered = allRooms.filter((room: any) => { - console.log(`[ChatBox] ๐Ÿ” Filtering room "${room.name}" with query: "${query}"`); - - if (!query || typeof query !== 'string') { - console.warn(`[ChatBox] โš ๏ธ Invalid query:`, query); - return false; - } - - if (!room.name || typeof room.name !== 'string') { - console.warn(`[ChatBox] โš ๏ธ Invalid room name:`, room.name); - return false; - } - - const matchesSearch = room.name.toLowerCase().includes(query.toLowerCase()) || - (room.description && room.description.toLowerCase().includes(query.toLowerCase())); - - // For public rooms, show them even if user is not a member (they can join) - // For private rooms, only show if user is already a member - const canAccess = room.type === 'public' || - (room.participants && room.participants.some((p: any) => { - const participantId = typeof p === 'string' ? p : p.id; - return participantId === props.userId.value; - })); - - console.log(`[ChatBox] ๐Ÿ” Room "${room.name}" (${room.type}): query="${query}", matchesSearch=${matchesSearch}, canAccess=${canAccess}, participants:`, room.participants); - - return matchesSearch && canAccess; - }); - - console.log('[ChatBox] ๐Ÿ” Filtered rooms:', filtered.map((r: any) => ({ - name: r.name, - id: r.id, - participants: r.participants?.length || 0 - }))); - - setSearchResults(filtered); - setShowSearchResults(true); - console.log('[ChatBox] ๐Ÿ” Search results:', filtered.length, 'rooms found'); - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error searching rooms:', error); - setSearchResults([]); - } finally { - setIsSearching(false); - } - }; - - const handleSearchInputChange = (e: React.ChangeEvent) => { - const query = e.target.value; - console.log(`[ChatBox] ๐Ÿ” Search input changed to: "${query}"`); - setSearchQuery(query); - - // Debounce search - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - searchTimeoutRef.current = setTimeout(() => { - console.log(`[ChatBox] ๐Ÿ” Executing debounced search with query: "${query}"`); - handleSearch(query); - }, 300); - }; - - const { - isConnected, - isLoading, - error, - currentRoom, - messages, - typingUsers, - sendMessage, - startTyping, - stopTyping - } = chatManager; - - // Message received event - useEffect(() => { - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; - if (lastMessage && lastMessage.authorId !== props.userId.value) { - triggerEvent("messageReceived"); - } - } - }, [messages]); - - // Typing events - useEffect(() => { - if (typingUsers && typingUsers.length > 0) { - triggerEvent("typingStarted"); - } else { - triggerEvent("typingStopped"); - } - }, [typingUsers]); - - - useEffect(() => { - if (chatAreaRef.current) { - chatAreaRef.current.scrollTop = chatAreaRef.current.scrollHeight; - } - }, [messages]); - - // Typing management - const typingTimeoutRef = useRef(null); - const isTypingRef = useRef(false); - - const handleInputChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setCurrentMessage(newValue); - - if (newValue.trim() && isConnected) { - // Only start typing if we weren't already typing - if (!isTypingRef.current) { - console.log('[ChatBox] ๐Ÿ–Š๏ธ Starting typing indicator'); - startTyping(); - isTypingRef.current = true; - } - - // Clear existing timeout - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - - // Set new timeout to stop typing after 2 seconds of inactivity - typingTimeoutRef.current = setTimeout(() => { - console.log('[ChatBox] ๐Ÿ–Š๏ธ Stopping typing indicator (timeout)'); - stopTyping(); - isTypingRef.current = false; - }, 2000); - } else if (!newValue.trim()) { - // Stop typing immediately if input is empty - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = null; - } - if (isTypingRef.current) { - console.log('[ChatBox] ๐Ÿ–Š๏ธ Stopping typing indicator (empty input)'); - stopTyping(); - isTypingRef.current = false; - } - } - }; - - const handleSendMessage = async () => { - if (currentMessage.trim()) { - // Stop typing before sending - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = null; - } - if (isTypingRef.current) { - console.log('[ChatBox] ๐Ÿ–Š๏ธ Stopping typing indicator (sending message)'); - stopTyping(); - isTypingRef.current = false; - } - - const success = await sendMessage(currentMessage.trim()); - - if (success) { - setCurrentMessage(""); - handleClickEvent(); - triggerEvent("messageSent"); - } - } - }; - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - // Clean up typing timeout on unmount - useEffect(() => { - return () => { - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - if (isTypingRef.current) { - stopTyping(); - } - }; - }, [stopTyping]); - - // Process rooms for display - const baseRooms = showSearchResults ? searchResults : joinedRooms; - const displayRooms = baseRooms.map((room: any) => ({ - id: room.id, - name: room.name, - type: room.type, - active: currentRoom?.id === room.id, - participantCount: room.participants?.length || 0, - canJoin: showSearchResults, // Can only join rooms found through search - isSearchResult: showSearchResults - })); - - // When showing search results, we don't need to add current room - // When showing joined rooms, all rooms are already joined by definition - - - - return ( - - {/* Left Panel - Combined Content */} - - {/* Connection Status */} - {!isConnected && ( - - {isLoading ? "Connecting..." : error ? `Error: ${error}` : "Disconnected"} - - )} - -
- {props.showRooms && ( -
-
- Chat Rooms -
-
- {/* Modern Create Room Modal */} - {/* Create Room Button - Modern Design */} - {props.allowRoomCreation && ( - - )} -
- - - {/* Modern Search UI */} -
- } - value={searchQuery} - onChange={handleSearchInputChange} - loading={isSearching} - style={{ - borderRadius: '6px', - marginBottom: '8px' - }} - size="middle" - allowClear - onClear={() => { - setSearchQuery(""); - setShowSearchResults(false); - setSearchResults([]); - }} - /> - {showSearchResults && ( -
-
0 ? '4px' : '0' - }}> - - - Search Results - -
- {searchResults.length === 0 ? ( -
- No rooms match "{searchQuery}" -
- ) : ( -
- Found {searchResults.length} room{searchResults.length === 1 ? '' : 's'} matching "{searchQuery}" -
- )} -
- )} -
- - {/* Clear Search Button - Modern */} - {showSearchResults && ( -
- -
- )} - - {/* Room List */} - {displayRooms.length === 0 && isConnected && ( -
- {showSearchResults ? ( - searchQuery ? `No rooms found for "${searchQuery}"` : 'Enter a search term to find rooms' - ) : ( - <> -
- ๐Ÿ  You haven't joined any rooms yet -
-
- {props.allowRoomCreation - ? 'Create a new room or search to join existing ones' - : 'Search to find and join existing rooms' - } -
- - )} -
- )} - {displayRooms.map((room: any) => ( - { - if (!room.active) { - if (room.canJoin && props.allowRoomJoining) { - // Join a new room from search results - handleJoinRoom(room.id); - } else if (!room.canJoin) { - // Switch to an already joined room - chatManager.setCurrentRoom(room.id); - } - } - }} - style={{ - cursor: (!room.active) ? 'pointer' : 'default', - opacity: room.active ? 1 : 0.8, - transition: 'all 0.2s', - border: room.active - ? '1px solid #52c41a' - : room.isSearchResult - ? '1px solid #d1ecf1' - : '1px solid transparent', - boxShadow: room.isSearchResult - ? '0 2px 4px rgba(0, 0, 0, 0.08)' - : undefined - }} - title={ - room.active - ? 'Current room' - : room.canJoin - ? `Click to join "${room.name}"` - : `Click to switch to "${room.name}"` - } - > - {/* Room Icon and Name */} -
- {room.type === 'public' ? ( - - ) : ( - - )} -
- - {room.name} - - - {/* Room Metadata */} -
- - - - {room.participantCount} - - - - {room.active && ( - - )} - - {room.isSearchResult && !room.active && ( -
- NEW -
- )} -
-
-
- - {/* Action Buttons */} -
- {room.canJoin && props.allowRoomJoining && ( - - - - )} - - {room.active && ( - - handleLeaveRoom(room.id)} - onCancel={() => {/* setRoomToLeave(null); */}} - okText="Leave" - cancelText="Cancel" - placement="bottomRight" - okButtonProps={{ danger: true }} - > -
-
- ))} -
-
- )} -
-
- - {/* Right Panel - Chat Area */} - - -
-
-
- {props.chatName.value} -
-
- {currentRoom?.name || "Default Room"} -
-
-
- {isConnected ? ( - - ) : ( - - )} -
- -
-
-
-
- - {/* Leave Room Confirmation */} - {/* Removed Popconfirm from here as it's now integrated into the room item */} - - - {messages.length === 0 ? ( - -
๐Ÿ’ฌ
-
No messages yet
-
- {isConnected ? "Start the conversation!" : "Connecting to chat..."} -
-
- ) : ( - messages.map((message: UnifiedMessage) => ( - -
- {message.authorName} -
- {message.text} -
- {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
- )) - )} -
- - {/* Typing Indicators */} - {typingUsers && typingUsers.length > 0 && ( - - {typingUsers.length === 1 ? ( - - {typingUsers[0].userName} is typing - - - - - - - ) : ( - - {typingUsers.length} people are typing - - - - - - - )} - - )} - - - - - Send - - -
- - {/* Modern Create Room Modal */} - - - Create New Room - - } - open={isCreateModalOpen} - onCancel={() => { - setIsCreateModalOpen(false); - createRoomForm.resetFields(); - }} - footer={null} - width={480} - centered - destroyOnHidden - > -
- { - // if (!value || value.length < 2) return; - // - // try { - // const allRooms = await chatManager.getAvailableRooms(); - // const roomExists = allRooms.some((room: any) => - // room.name.toLowerCase() === value.toLowerCase() - // ); - // - // if (roomExists) { - // throw new Error('A room with this name already exists'); - // } - // } catch (error) { - // if (error instanceof Error && error.message.includes('already exists')) { - // throw error; - // } - // // If there's an API error, don't block the validation - // console.warn('Could not validate room name uniqueness:', error); - // } - // } - // } - ]} - > - - - - - - - - - - - - - -
-
Public Room
-
- Anyone can discover and join this room -
-
-
-
- - - -
-
Private Room
-
- Only invited members can join this room -
-
-
-
-
-
-
- - - - - - - -
-
-
- ); -}); - -// Build the component -let ChatBoxTmpComp = (function () { - return new UICompBuilder(chatCompChildrenMap, (props) => ) - .setPropertyViewFn((children) => ) - .build(); -})(); - -ChatBoxTmpComp = class extends ChatBoxTmpComp { - override autoHeight(): boolean { - return this.children.autoHeight.getView(); - } -}; - -// Add method exposing -ChatBoxTmpComp = withMethodExposing(ChatBoxTmpComp, [ - { - method: { - name: "joinUser", - description: "Allow users to join the chat server with their own credentials", - params: [ - { - name: "userId", - type: "string", - }, - { - name: "userName", - type: "string", - }, - ], - }, - execute: async (comp: any, values: any) => { - return await handleJoinUser(comp, values?.[0], values?.[1]); - }, - }, -]); - -export const ChatBoxComp = withExposingConfigs(ChatBoxTmpComp, [ - new NameConfig("chatName", "Chat name displayed in header"), - new NameConfig("userId", "Unique identifier for current user"), - new NameConfig("userName", "Display name for current user"), - new NameConfig("applicationId", "Application scope identifier for room discovery"), - new NameConfig("roomId", "Initial room to join within application scope"), - NameConfigHidden, -]); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatControllerComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatControllerComp.tsx deleted file mode 100644 index e289d4629d..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatControllerComp.tsx +++ /dev/null @@ -1,624 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { ResizeHandle } from "react-resizable"; -import { v4 as uuidv4 } from "uuid"; -import { chatCompChildrenMap, ChatCompChildrenType, ChatPropertyView } from "./chatUtils"; -import { trans } from "i18n"; -import { ToViewReturn } from "@lowcoder-ee/comps/generators/multi"; -import Form from "antd/es/form"; -import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; -import { useChatManager, UseChatManagerReturn } from "./hooks/useChatManager"; -import { ContainerChildren, ContainerCompBuilder } from "../containerBase/containerCompBuilder"; -import { withMethodExposing } from "@lowcoder-ee/comps/generators/withMethodExposing"; -import { BackgroundColorContext } from "@lowcoder-ee/comps/utils/backgroundColorContext"; -import Drawer from "antd/es/drawer"; -import { isNumeric } from "@lowcoder-ee/util/stringUtils"; -import { gridItemCompToGridItems, InnerGrid } from "../containerComp/containerView"; -import { HintPlaceHolder } from "components/container"; -import { NameConfig, withExposingConfigs } from "@lowcoder-ee/comps/generators/withExposing"; -import { BooleanStateControl } from "@lowcoder-ee/comps/controls/codeStateControl"; -import { StringControl } from "@lowcoder-ee/comps/controls/codeControl"; -import { stateComp, withDefault } from "@lowcoder-ee/comps/generators/simpleGenerators"; -import { PositionControl } from "@lowcoder-ee/comps/controls/dropdownControl"; -import { BoolControl } from "@lowcoder-ee/comps/controls/boolControl"; -import { NewChildren } from "@lowcoder-ee/comps/generators/uiCompBuilder"; -import { changeChildAction, ConstructorToComp, DispatchType, RecordConstructorToComp } from "lowcoder-core"; -import { Layers } from "@lowcoder-ee/constants/Layers"; -import { JSONObject } from "@lowcoder-ee/util/jsonTypes"; - -const DEFAULT_SIZE = 378; -const DEFAULT_PADDING = 16; -function transToPxSize(size: string | number) { - return isNumeric(size) ? size + "px" : (size as string); -} - -const handleCreateRoom = async ( - comp: ConstructorToComp, - roomData: { - name: string, - description: string, - private: boolean, - }, -) => { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - const userId = comp.children.userId.getView(); - const userName = comp.children.userName.getView(); - - try { - const newRoom = await chatManager.createRoomFromRequest({ - name: roomData.name.trim(), - type: roomData.private ? "private" : "public", - description: roomData.description || `Created by ${userName}` - }); - - if (newRoom) { - console.log('[ChatBox] โœ… Created room:', newRoom.name); - - // Automatically join the room as the creator - const joinSuccess = await chatManager.joinRoom(newRoom.id); - return joinSuccess; - } - } catch (error) { - console.error('Failed to create room:', error); - } -}; - -const handleJoinRoom = async ( - comp: ConstructorToComp, - roomId: string, -) => { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - try { - const success = await chatManager.joinRoom(roomId); - if (success) { - // Note: Event will be triggered by the component's useEffect hooks - console.log('[ChatController] โœ… Successfully joined room:', roomId); - } else { - console.error('[ChatBox] โŒ Failed to join room:', roomId); - } - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error joining room:', error); - } -}; - - -const handleLeaveRoom = async ( - comp: ConstructorToComp, - roomId: string, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - console.log('[ChatBox] ๐Ÿšช Attempting to leave room:', roomId); - - const success = await chatManager.leaveRoom(roomId); - if (success) { - // Note: Event will be triggered by the component's useEffect hooks - console.log('[ChatController] โœ… Successfully left room:', roomId); - } - return success; - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error leaving room:', error); - } -}; - -const handleSetCurrentRoom = async ( - comp: ConstructorToComp, - roomId: string, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - await chatManager.setCurrentRoom(roomId); - } catch (error) { - console.error('Failed to set current room:', error); - } -}; - -const handleSendMessage = async ( - comp: ConstructorToComp, - currentMessage: string, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - if (currentMessage.trim()) { - const success = await chatManager.sendMessage(currentMessage.trim()); - if (success) { - // Note: Event will be triggered by the component's useEffect hooks - console.log('[ChatController] โœ… Message sent successfully'); - } - return success; - } - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error sending message:', error); - } -}; - -const handleStartTyping = ( - comp: ConstructorToComp, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - chatManager.startTyping(); - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error starting typing:', error); - } -}; - -const handleStopTyping = ( - comp: ConstructorToComp, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - chatManager.stopTyping(); - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error stopping typing:', error); - } -}; - -const handleJoinUser = async ( - comp: ConstructorToComp, - userId: string, - userName: string, -) => { - try { - // Update the component's internal state with public user credentials - comp.children.userId.getView().onChange(userId); - comp.children.userName.getView().onChange(userName); - - console.log('[ChatController] ๐Ÿ‘ค Public user joined as:', { userId, userName }); - - // The chat manager will automatically reconnect with new credentials - // due to the useEffect that watches for userId/userName changes - return true; - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Error joining as public user:', error); - return false; - } -}; - -const childrenMap = { - ...chatCompChildrenMap, - visible: withDefault(BooleanStateControl, "false"), - width: StringControl, - height: StringControl, - placement: PositionControl, - maskClosable: withDefault(BoolControl, true), - showMask: withDefault(BoolControl, true), - rooms: stateComp([]), - messages: stateComp([]), - chatManager: stateComp({}), - participants: stateComp([]), - currentRoom: stateComp(null), - typingUsers: stateComp([]), -} - -type ChatControllerChildrenType = NewChildren>; - -const CanvasContainerID = "__canvas_container__"; - -const ChatBoxView = React.memo(( - props: ToViewReturn> & { dispatch: DispatchType }, -) => { - const { dispatch } = props; - const { items, ...otherContainerProps } = props.container; - const isTopBom = ["top", "bottom"].includes(props.placement); - const [currentMessage, setCurrentMessage] = useState(""); - const [joinedRooms, setJoinedRooms] = useState([]); - const [currentRoomParticipants, setCurrentRoomParticipants] = useState>([]); - const handleClickEvent = useCompClickEventHandler({onEvent: props.onEvent}); - - // Helper function to trigger custom events - const triggerEvent = (eventName: string) => { - if (props.onEvent) { - props.onEvent(eventName); - } - }; - - // Initialize chat manager - const modeValue = props.mode as 'local' | 'collaborative' | 'hybrid'; - - // Only initialize chat manager if userId and userName are provided - const shouldInitialize = !!(props.userId.value && props.userName.value); - - const chatManager = useChatManager({ - userId: props.userId.value, - userName: props.userName.value, - applicationId: props.applicationId.value, - roomId: props.roomId.value, - mode: modeValue, // Use mode from props - autoConnect: shouldInitialize, // Only auto-connect if credentials are provided - }); - - useEffect(() => { - if (!chatManager.isConnected) return; - - dispatch( - changeChildAction("chatManager", chatManager as any, false) - ) - }, [chatManager.isConnected]); - - const loadRooms = useCallback(async () => { - if (!chatManager.isConnected) return; - try { - const allRooms = await chatManager.getAvailableRooms(); - - if (!allRooms || !Array.isArray(allRooms)) { - // Keep existing joined rooms if API fails - return; - } - console.log('[ChatBox] ๐Ÿ“‹ Found joined rooms:', allRooms.map((r: any) => r.name)); - - setJoinedRooms(allRooms); - dispatch( - changeChildAction("rooms", allRooms as any, false) - ) - } catch (error) { - console.error('[ChatBox] ๐Ÿ’ฅ Failed to load joined rooms:', error); - } - }, [chatManager.isConnected, dispatch]); - - // Load joined rooms when connected - useEffect(() => { - if (chatManager.isConnected) { - loadRooms(); - } - }, [chatManager.isConnected, props.userId.value, loadRooms]); - - // Handle reconnection when userId or userName changes - useEffect(() => { - if (props.userId.value && props.userName.value) { - if (chatManager.isConnected) { - // Disconnect and let the chat manager reconnect with new credentials - chatManager.disconnect().then(() => { - console.log('[ChatController] ๐Ÿ”„ Reconnecting with new user credentials'); - }); - } else { - // If not connected and we have credentials, trigger connection - console.log('[ChatController] ๐Ÿ”Œ Connecting with user credentials'); - } - } - }, [props.userId.value, props.userName.value]); - - // Chat event handlers - useEffect(() => { - if (chatManager.isConnected) { - triggerEvent("connected"); - } else if (chatManager.error) { - triggerEvent("error"); - } - }, [chatManager.isConnected, chatManager.error]); - - // Refresh joined rooms periodically - useEffect(() => { - if (!chatManager.isConnected) return; - - const refreshInterval = setInterval(async () => { - loadRooms(); - }, 10000); // Refresh every 10 seconds - - return () => clearInterval(refreshInterval); - }, [chatManager.isConnected, props.userId.value, loadRooms]); - - const { - isConnected, - isLoading, - error, - currentRoom, - messages, - typingUsers, - sendMessage, - startTyping, - stopTyping, - getRoomParticipants - } = chatManager; - - useEffect(() => { - if (!isConnected) return; - - dispatch( - changeChildAction("messages", messages as any, false) - ) - }, [isConnected, messages]); - - // Load participants when current room changes - useEffect(() => { - const loadParticipants = async () => { - if (currentRoom && getRoomParticipants) { - try { - const participants = await getRoomParticipants(currentRoom.id); - setCurrentRoomParticipants(participants); - console.log('[ChatController] ๐Ÿ‘ฅ Loaded participants for room:', currentRoom.name, participants); - } catch (error) { - console.error('[ChatController] Failed to load participants:', error); - } - } - }; - - loadParticipants(); - }, [currentRoom, getRoomParticipants]); - - // Update participants state - useEffect(() => { - if (!chatManager.isConnected) return; - - dispatch( - changeChildAction("participants", currentRoomParticipants as any, false) - ); - }, [currentRoomParticipants]); - - // Update currentRoom state - useEffect(() => { - if (!chatManager.isConnected) return; - - dispatch( - changeChildAction("currentRoom", currentRoom as any, false) - ); - - // Trigger room joined event when currentRoom changes to a new room - if (currentRoom) { - triggerEvent("roomJoined"); - } - }, [currentRoom]); - - // Update typingUsers state - useEffect(() => { - if (!chatManager.isConnected) return; - - dispatch( - changeChildAction("typingUsers", typingUsers as any, false) - ); - }, [typingUsers]); - - // Message events - useEffect(() => { - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; - if (lastMessage) { - if (lastMessage.authorId === props.userId.value) { - // Message sent by current user - triggerEvent("messageSent"); - } else { - // Message received from another user - triggerEvent("messageReceived"); - } - } - } - }, [messages, props.userId.value]); - - // Typing events - useEffect(() => { - if (typingUsers && typingUsers.length > 0) { - triggerEvent("typingStarted"); - } else { - triggerEvent("typingStopped"); - } - }, [typingUsers]); - - return ( - - {/* */} - - document.querySelector(`#${CanvasContainerID}`) || document.body - } - footer={null} - width={transToPxSize(props.width || DEFAULT_SIZE)} - height={ - !props.autoHeight - ? transToPxSize(props.height || DEFAULT_SIZE) - : "" - } - onClose={(e: any) => { - props.visible.onChange(false); - }} - afterOpenChange={(visible: any) => { - if (!visible) { - props.onEvent("close"); - } - }} - zIndex={Layers.drawer} - maskClosable={props.maskClosable} - mask={props.showMask} - > - - - - ); -}); - -let ChatControllerComp = new ContainerCompBuilder( - childrenMap, - (props, dispatch) => -) - .setPropertyViewFn((children) => ) - .build(); - -ChatControllerComp = class extends ChatControllerComp { - autoHeight(): boolean { - return false; - } - - -}; - -ChatControllerComp = withMethodExposing(ChatControllerComp, [ - { - method: { - name: "createRoom", - params: [ - { - name: "name", - type: "string", - }, - { - name: "description", - type: "string", - }, - { - name: "private", - type: "boolean", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleCreateRoom(comp, { - name: values?.[0], - private: values?.[1], - description: values?.[2], - }); - }, - }, - { - method: { - name: "setCurrentRoom", - params: [ - { - name: "roomId", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleSetCurrentRoom(comp, values?.[0]); - }, - }, - { - method: { - name: "joinRoom", - params: [ - { - name: "roomId", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleJoinRoom(comp, values?.[0]); - }, - }, - { - method: { - name: "leaveRoom", - params: [ - { - name: "roomId", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleLeaveRoom(comp, values?.[0]); - }, - }, - { - method: { - name: "startTyping", - params: [], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleStartTyping(comp); - }, - }, - { - method: { - name: "stopTyping", - params: [], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleStopTyping(comp); - }, - }, - { - method: { - name: "sendMessage", - params: [ - { - name: "message", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleSendMessage(comp, values?.[0]); - }, - }, - { - method: { - name: "getRoomParticipants", - description: "Get participants of a room with their ID and name", - params: [ - { - name: "roomId", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - const chatManager = comp.children.chatManager.getView() as any; - if (chatManager && chatManager.getRoomParticipants) { - return await chatManager.getRoomParticipants(values?.[0]); - } - return []; - }, - }, - { - method: { - name: "joinUser", - description: "Allow users to join the chat server with their own credentials", - params: [ - { - name: "userId", - type: "string", - }, - { - name: "userName", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - return await handleJoinUser(comp, values?.[0], values?.[1]); - }, - }, -]); - -ChatControllerComp = withExposingConfigs(ChatControllerComp, [ - new NameConfig("chatName", trans("chatBox.chatName")), - new NameConfig("rooms", trans("chatBox.rooms")), - new NameConfig("messages", trans("chatBox.messages")), - new NameConfig("participants", trans("chatBox.participants")), - new NameConfig("currentRoom", trans("chatBox.currentRoom")), - new NameConfig("typingUsers", trans("chatBox.typingUsers")), - new NameConfig("allowRoomCreation", trans("chatBox.allowRoomCreation")), - new NameConfig("allowRoomJoining", trans("chatBox.allowRoomJoining")), - new NameConfig("roomPermissionMode", trans("chatBox.roomPermissionMode")), - new NameConfig("userId", trans("chatBox.userId")), - new NameConfig("userName", trans("chatBox.userName")), -]); - -export { ChatControllerComp }; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatUtils.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatUtils.tsx deleted file mode 100644 index 3f0fa5e396..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatUtils.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { AutoHeightControl } from "@lowcoder-ee/comps/controls/autoHeightControl"; -import { BoolControl } from "@lowcoder-ee/comps/controls/boolControl"; -import { StringControl } from "@lowcoder-ee/comps/controls/codeControl"; -import { stringExposingStateControl } from "@lowcoder-ee/comps/controls/codeStateControl"; -import { dropdownControl } from "@lowcoder-ee/comps/controls/dropdownControl"; -import { clickEvent, doubleClickEvent, eventHandlerControl } from "@lowcoder-ee/comps/controls/eventHandlerControl"; -import { styleControl } from "@lowcoder-ee/comps/controls/styleControl"; -import { AnimationStyle, TextStyle } from "@lowcoder-ee/comps/controls/styleControlConstants"; -import { EditorContext } from "@lowcoder-ee/comps/editorState"; -import { withDefault } from "@lowcoder-ee/comps/generators/simpleGenerators"; -import { NewChildren } from "@lowcoder-ee/comps/generators/uiCompBuilder"; -import { hiddenPropertyView } from "@lowcoder-ee/comps/utils/propertyUtils"; -import { RecordConstructorToComp } from "lowcoder-core"; -import { ScrollBar, Section, sectionNames } from "lowcoder-design"; -import React, { useContext, useMemo } from "react"; -import { trans } from "i18n"; - -// Event options for the chat component -const EventOptions = [ - clickEvent, - doubleClickEvent, - { label: trans("chatBox.connected"), value: "connected", description: trans("chatBox.connectedDesc") }, - { label: trans("chatBox.disconnected"), value: "disconnected", description: trans("chatBox.disconnectedDesc") }, - { label: trans("chatBox.messageReceived"), value: "messageReceived", description: trans("chatBox.messageReceivedDesc") }, - { label: trans("chatBox.messageSent"), value: "messageSent", description: trans("chatBox.messageSentDesc") }, - { label: trans("chatBox.userJoined"), value: "userJoined", description: trans("chatBox.userJoinedDesc") }, - { label: trans("chatBox.userLeft"), value: "userLeft", description: trans("chatBox.userLeftDesc") }, - { label: trans("chatBox.typingStarted"), value: "typingStarted", description: trans("chatBox.typingStartedDesc") }, - { label: trans("chatBox.typingStopped"), value: "typingStopped", description: trans("chatBox.typingStoppedDesc") }, - { label: trans("chatBox.roomJoined"), value: "roomJoined", description: trans("chatBox.roomJoinedDesc") }, - { label: trans("chatBox.roomLeft"), value: "roomLeft", description: trans("chatBox.roomLeftDesc") }, - { label: trans("chatBox.error"), value: "error", description: trans("chatBox.errorDesc") }, -] as const; - -// Define the component's children map -export const chatCompChildrenMap = { - chatName: stringExposingStateControl("chatName", "Chat Room"), - userId: stringExposingStateControl("userId", "user_1"), - userName: stringExposingStateControl("userName", "User"), - applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), - roomId: stringExposingStateControl("roomId", "general"), - mode: dropdownControl([ - { label: "๐ŸŒ Collaborative (Real-time)", value: "collaborative" }, - { label: "๐Ÿ”€ Hybrid (Local + Real-time)", value: "hybrid" }, - { label: "๐Ÿ“ฑ Local Only", value: "local" } - ], "collaborative"), - - // Room Management Configuration - allowRoomCreation: withDefault(BoolControl, true), - allowRoomJoining: withDefault(BoolControl, true), - roomPermissionMode: dropdownControl([ - { label: "๐ŸŒ Open (Anyone can join public rooms)", value: "open" }, - { label: "๐Ÿ” Invite Only (Admin invitation required)", value: "invite" }, - { label: "๐Ÿ‘ค Admin Only (Only admins can manage)", value: "admin" } - ], "open"), - showAvailableRooms: withDefault(BoolControl, true), - maxRoomsDisplay: withDefault(StringControl, "10"), - - // UI Configuration - leftPanelWidth: withDefault(StringControl, "200px"), - showRooms: withDefault(BoolControl, true), - autoHeight: AutoHeightControl, - onEvent: eventHandlerControl(EventOptions), - style: styleControl(TextStyle, 'style'), - animationStyle: styleControl(AnimationStyle, 'animationStyle'), -}; - -export type ChatCompChildrenType = NewChildren>; - -// Property view component -export const ChatPropertyView = React.memo((props: { - children: ChatCompChildrenType -}) => { - const editorContext = useContext(EditorContext); - const editorModeStatus = useMemo(() => editorContext.editorModeStatus, [editorContext.editorModeStatus]); - - const basicSection = useMemo(() => ( -
- {props.children.chatName.propertyView({ - label: "Chat Name", - tooltip: "Name displayed in the chat header" - })} - {props.children.userId.propertyView({ - label: "User ID", - tooltip: "Unique identifier for the current user" - })} - {props.children.userName.propertyView({ - label: "User Name", - tooltip: "Display name for the current user" - })} - {props.children.applicationId.propertyView({ - label: "Application ID", - tooltip: "Unique identifier for this Lowcoder application - all chat components with the same Application ID can discover each other's rooms" - })} - {props.children.roomId.propertyView({ - label: "Initial Room", - tooltip: "Default room to join when the component loads (within the application scope)" - })} - {props.children.mode.propertyView({ - label: "Sync Mode", - tooltip: "Choose how messages are synchronized: Collaborative (real-time), Hybrid (local + real-time), or Local only" - })} -
- ), [props.children]); - - const roomManagementSection = useMemo(() => ( -
- {props.children.allowRoomCreation.propertyView({ - label: "Allow Room Creation", - tooltip: "Allow users to create new chat rooms" - })} - {props.children.allowRoomJoining.propertyView({ - label: "Allow Room Joining", - tooltip: "Allow users to join existing rooms" - })} - {props.children.roomPermissionMode.propertyView({ - label: "Permission Mode", - tooltip: "Control how users can join rooms" - })} - {props.children.showAvailableRooms.propertyView({ - label: "Show Available Rooms", - tooltip: "Display list of available rooms to join" - })} - {props.children.maxRoomsDisplay.propertyView({ - label: "Max Rooms to Display", - tooltip: "Maximum number of rooms to show in the list" - })} -
- ), [props.children]); - - const interactionSection = useMemo(() => - ["logic", "both"].includes(editorModeStatus) && ( -
- {hiddenPropertyView(props.children)} - {props.children.onEvent.getPropertyView()} -
- ), [editorModeStatus, props.children]); - - const layoutSection = useMemo(() => - ["layout", "both"].includes(editorModeStatus) && ( - <> -
- {props.children.autoHeight.getPropertyView()} - {props.children.leftPanelWidth.propertyView({ - label: "Left Panel Width", - tooltip: "Width of the rooms/people panel (e.g., 300px, 25%)" - })} - {props.children.showRooms.propertyView({ - label: "Show Rooms" - })} -
-
- {props.children.style.getPropertyView()} -
-
- {props.children.animationStyle.getPropertyView()} -
- - ), [editorModeStatus, props.children]); - - return ( - <> - {basicSection} - {roomManagementSection} - {interactionSection} - {layoutSection} - - ); -}); \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/hooks/useChatManager.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/hooks/useChatManager.ts deleted file mode 100644 index cd0df73293..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/hooks/useChatManager.ts +++ /dev/null @@ -1,617 +0,0 @@ -// React hook for managing chat data through HybridChatManager -// Provides a clean interface for ChatBoxComponent to use our data layer - -import { useEffect, useRef, useState, useCallback } from 'react'; -import { HybridChatManager, HybridChatManagerConfig, ManagerEvent } from '../managers/HybridChatManager'; -import { UnifiedMessage, UnifiedRoom, ConnectionState, ChatEvent, TypingState, CreateRoomRequest, JoinRoomRequest, RoomListFilter } from '../types/chatDataTypes'; - -// Hook configuration -export interface UseChatManagerConfig { - userId: string; - userName: string; - applicationId: string; - roomId: string; - mode?: 'local' | 'collaborative' | 'hybrid'; - autoConnect?: boolean; - dbName?: string; -} - -// Hook return type -export interface UseChatManagerReturn { - // Connection state - isConnected: boolean; - connectionState: ConnectionState; - isLoading: boolean; - error: string | null; - - // Current room data - currentRoom: UnifiedRoom | null; - messages: UnifiedMessage[]; - typingUsers: TypingState[]; - - // Operations - sendMessage: (text: string, messageType?: 'text' | 'system') => Promise; - loadMoreMessages: () => Promise; - refreshMessages: () => Promise; - - // Typing indicators - startTyping: () => Promise; - stopTyping: () => Promise; - - // Room management - setCurrentRoom: (roomId: string) => Promise; - createRoom: (name: string, type?: 'private' | 'public' | 'group') => Promise; - - // Enhanced room management - createRoomFromRequest: (request: CreateRoomRequest) => Promise; - getAvailableRooms: (filter?: RoomListFilter) => Promise; - joinRoom: (roomId: string) => Promise; - leaveRoom: (roomId: string) => Promise; - canUserJoinRoom: (roomId: string) => Promise; - getRoomParticipants: (roomId: string) => Promise>; - - // Manager access (for advanced use) - manager: HybridChatManager | null; - - // Cleanup - disconnect: () => Promise; -} - -export function useChatManager(config: UseChatManagerConfig): UseChatManagerReturn { - // State management - const [isConnected, setIsConnected] = useState(false); - const [connectionState, setConnectionState] = useState('disconnected'); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [currentRoom, setCurrentRoom] = useState(null); - const [messages, setMessages] = useState([]); - const [typingUsers, setTypingUsers] = useState([]); - - // Manager reference - const managerRef = useRef(null); - const unsubscribeRefs = useRef<(() => void)[]>([]); - - // Initialize manager - const initializeManager = useCallback(async () => { - if (managerRef.current) { - return; // Already initialized - } - setIsLoading(true); - setError(null); - - try { - console.log(`[ChatManager] ๐Ÿ—๏ธ Initializing chat manager for user ${config.userId} in application ${config.applicationId}`); - - const managerConfig: HybridChatManagerConfig = { - mode: config.mode || 'collaborative', // Default to collaborative - userId: config.userId, - userName: config.userName, - applicationId: config.applicationId, - local: { - // Use applicationId for database scoping so all components within the same - // Lowcoder application share the same ALASql database. This enables - // cross-component room discovery while maintaining application isolation. - dbName: config.dbName || `ChatDB_App_${config.applicationId}`, - }, - // ๐Ÿงช TEST: Add collaborative config to enable YjsPluvProvider for testing - // This enables testing of the Yjs document structure (Step 1) - collaborative: { - serverUrl: 'ws://localhost:3005', // Placeholder - not used in Step 1 - roomId: config.roomId, - authToken: undefined, - autoConnect: true, - }, - autoReconnect: true, - reconnectDelay: 2000, - }; - - const manager = new HybridChatManager(managerConfig); - managerRef.current = manager; - - // Set up connection state listener - const connectionUnsub = manager.subscribeToConnection((state) => { - setConnectionState(state); - setIsConnected(state === 'connected'); - - if (state === 'failed') { - setError('Connection failed'); - } else if (state === 'connected') { - setError(null); - } - }); - unsubscribeRefs.current.push(connectionUnsub); - - // Set up manager event listener - const managerUnsub = manager.subscribeToManagerEvents((event: ManagerEvent) => { - if (event.type === 'sync_failed') { - setError(event.error || 'Sync failed'); - } - }); - unsubscribeRefs.current.push(managerUnsub); - - // Initialize the manager - const result = await manager.initialize(); - - if (!result.success) { - throw new Error(result.error || 'Failed to initialize chat manager'); - } - - // Set up initial room - await setupCurrentRoom(manager, config.roomId); - - } catch (err) { - console.error('[ChatManager] Failed to initialize chat manager:', err); - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setIsLoading(false); - } - }, [config.userId, config.userName, config.applicationId, config.mode, config.dbName]); - - // Setup current room and message subscription - const setupCurrentRoom = useCallback(async (manager: HybridChatManager, roomIdentifier: string) => { - try { - console.log(`[ChatManager] ๐Ÿ  Setting up room: "${roomIdentifier}" for user: ${config.userId}`); - console.log(`[ChatManager] ๐Ÿ  Application scope: ${config.applicationId}`); - - // Try to get existing room by name first - console.log(`[ChatManager] ๐Ÿ” Searching for room by name: "${roomIdentifier}"`); - let roomResult = await manager.getRoomByName(roomIdentifier); - console.log(`[ChatManager] ๐Ÿ” getRoomByName result:`, roomResult); - - if (!roomResult.success) { - // Fallback to searching by ID for backward compatibility - console.log(`[ChatManager] ๐Ÿ” Room not found by name, trying by ID: "${roomIdentifier}"`); - roomResult = await manager.getRoom(roomIdentifier); - console.log(`[ChatManager] ๐Ÿ” getRoom result:`, roomResult); - } - - if (!roomResult.success) { - // Create room if it doesn't exist - console.log(`[ChatManager] ๐Ÿ—๏ธ Creating new room: "${roomIdentifier}" as public`); - const createResult = await manager.createRoom({ - name: roomIdentifier, // Use the identifier as the name - type: 'public', // Make initial rooms public so they can be discovered - participants: [config.userId], - admins: [config.userId], - creator: config.userId, - isActive: true, - lastActivity: Date.now() - }); - - if (!createResult.success) { - throw new Error(createResult.error || 'Failed to create room'); - } - - console.log(`[ChatManager] โœ… Created room:`, createResult.data); - roomResult = createResult; - } else { - // Room exists - check if user is a participant, if not, join them - const room = roomResult.data!; - console.log(`[ChatManager] ๐Ÿ  Found existing room:`, room); - - const isUserParticipant = room.participants?.some((p: any) => { - const participantId = typeof p === 'string' ? p : p.id; - return participantId === config.userId; - }); - - console.log(`[ChatManager] ๐Ÿ‘ค User ${config.userId} is ${isUserParticipant ? 'already' : 'NOT'} a participant`); - - if (!isUserParticipant) { - console.log(`[ChatManager] ๐Ÿšช User not in room "${roomIdentifier}", attempting to join...`); - try { - await manager.joinRoom({ - roomId: room.id, - userId: config.userId, - userName: config.userName - }); - // Refresh room data after joining - roomResult = await manager.getRoom(room.id); - console.log(`[ChatManager] โœ… Successfully joined room, updated data:`, roomResult.data); - } catch (joinError) { - console.warn(`[ChatManager] โš ๏ธ Failed to auto-join room "${roomIdentifier}":`, joinError); - // Continue anyway - user might still be able to use the room - } - } - } - - setCurrentRoom(roomResult.data!); - - // Subscribe to room events - const roomUnsub = manager.subscribeToRoom(roomResult.data!.id, (event: ChatEvent) => { - if (event.type === 'message_added') { - setMessages(prev => { - const newMessages = [...prev, event.data as UnifiedMessage]; - return newMessages; - }); - } else if (event.type === 'message_updated') { - setMessages(prev => prev.map(msg => - msg.id === event.data.id ? { ...msg, ...event.data } : msg - )); - } else if (event.type === 'message_deleted') { - setMessages(prev => prev.filter(msg => msg.id !== event.data.messageId)); - } else if (event.type === 'typing_started') { - console.log('[ChatManager] ๐Ÿ–Š๏ธ User started typing:', event.data); - setTypingUsers(prev => { - const existing = prev.find(user => user.userId === event.data.userId); - if (existing) return prev; // Already typing - return [...prev, event.data]; - }); - } else if (event.type === 'typing_stopped') { - console.log('[ChatManager] ๐Ÿ–Š๏ธ User stopped typing:', event.data); - setTypingUsers(prev => prev.filter(user => user.userId !== event.data.userId)); - } - }); - unsubscribeRefs.current.push(roomUnsub); - - // Load initial messages - await loadMessages(manager, roomResult.data!.id); - - } catch (err) { - console.error('[ChatManager] Error in setupCurrentRoom:', err); - setError(err instanceof Error ? err.message : 'Failed to setup room'); - } - }, [config.userId]); // Remove loadMessages from dependencies to avoid circular dependency - - // Load messages for current room - const loadMessages = useCallback(async (manager: HybridChatManager, roomId: string, before?: number) => { - try { - const result = await manager.getMessages(roomId, 50, before); - - if (result.success) { - if (before) { - // Prepend older messages - setMessages(prev => [...result.data!, ...prev]); - } else { - // Set initial messages - setMessages(result.data!); - } - } else { - console.error('Failed to load messages:', result.error); - } - } catch (err) { - console.error('Error loading messages:', err); - } - }, []); - - // Send message - const sendMessage = useCallback(async (text: string, messageType: 'text' | 'system' = 'text'): Promise => { - const manager = managerRef.current; - - if (!manager || !currentRoom) { - setError('Chat not connected'); - return false; - } - - if (!text.trim()) { - return false; - } - - try { - const messageObj = { - text: text.trim(), - authorId: config.userId, - authorName: config.userName, - roomId: currentRoom.id, - messageType, - }; - - const result = await manager.sendMessage(messageObj); - - if (!result.success) { - setError(result.error || 'Failed to send message'); - return false; - } - - // Message will be added via subscription - return true; - } catch (err) { - console.error('[ChatManager] Error sending message:', err); - setError(err instanceof Error ? err.message : 'Failed to send message'); - return false; - } - }, [config.userId, config.userName, currentRoom]); - - // Load more messages (pagination) - const loadMoreMessages = useCallback(async () => { - const manager = managerRef.current; - if (!manager || !currentRoom || messages.length === 0) { - return; - } - - const oldestMessage = messages[0]; - await loadMessages(manager, currentRoom.id, oldestMessage.timestamp); - }, [currentRoom, messages, loadMessages]); - - // Refresh messages - const refreshMessages = useCallback(async () => { - const manager = managerRef.current; - if (!manager || !currentRoom) { - return; - } - - await loadMessages(manager, currentRoom.id); - }, [currentRoom, loadMessages]); - - // Set current room - const setCurrentRoomById = useCallback(async (roomId: string) => { - const manager = managerRef.current; - if (!manager) { - return; - } - - // Clean up existing room subscription - unsubscribeRefs.current.forEach(unsub => unsub()); - unsubscribeRefs.current = []; - - await setupCurrentRoom(manager, roomId); - }, [setupCurrentRoom]); - - // Create new room - const createRoom = useCallback(async (name: string, type: 'private' | 'public' | 'group' = 'private'): Promise => { - const manager = managerRef.current; - if (!manager) { - return null; - } - - try { - const result = await manager.createRoom({ - name, - type, - participants: [config.userId], - admins: [config.userId], - creator: config.userId, - isActive: true, - lastActivity: Date.now(), - }); - - if (result.success) { - return result.data!.id; - } else { - setError(result.error || 'Failed to create room'); - return null; - } - } catch (err) { - console.error('Error creating room:', err); - setError(err instanceof Error ? err.message : 'Failed to create room'); - return null; - } - }, [config.userId]); - - // Disconnect - const disconnect = useCallback(async () => { - const manager = managerRef.current; - if (!manager) { - return; - } - - // Clean up subscriptions - unsubscribeRefs.current.forEach(unsub => unsub()); - unsubscribeRefs.current = []; - - // Disconnect manager - await manager.disconnect(); - managerRef.current = null; - - // Reset state - setIsConnected(false); - setConnectionState('disconnected'); - setCurrentRoom(null); - setMessages([]); - setTypingUsers([]); - setError(null); - }, []); - - // Typing indicator functions - const startTyping = useCallback(async () => { - const manager = managerRef.current; - if (!manager || !currentRoom) return; - - try { - await manager.startTyping(currentRoom.id); - } catch (error) { - console.error('[ChatManager] Failed to start typing:', error); - } - }, [currentRoom]); - - const stopTyping = useCallback(async () => { - const manager = managerRef.current; - if (!manager || !currentRoom) return; - - try { - await manager.stopTyping(currentRoom.id); - } catch (error) { - console.error('[ChatManager] Failed to stop typing:', error); - } - }, [currentRoom]); - - // Auto-connect on mount - useEffect(() => { - if (config.autoConnect !== false) { - initializeManager(); - } - - return () => { - // Cleanup on unmount - disconnect(); - }; - }, [config.autoConnect, initializeManager]); - - // Update room when roomId changes - useEffect(() => { - if (managerRef.current && isConnected && config.roomId) { - setCurrentRoomById(config.roomId); - } - }, [config.roomId, isConnected, setCurrentRoomById]); - - // ------------------------------------------------------------ - // Cross-component message propagation (same browser tab) - // ------------------------------------------------------------ - // Each ALASqlProvider instance fires a CustomEvent on `window` when it inserts - // a new message. Listen for that here so that *other* ChatBox components that - // use a different provider instance (e.g. because they have a different - // userId) immediately receive the update without refreshing. - useEffect(() => { - const handler = (e: any) => { - const { roomId, message } = (e as CustomEvent).detail || {}; - if (!roomId || !message) return; - // Only handle messages for the current room that were not sent by *this* user - if (roomId === currentRoom?.id && message.authorId !== config.userId) { - setMessages((prev) => { - if (prev.some((m) => m.id === message.id)) return prev; // de-dupe - return [...prev, message]; - }); - } - }; - window.addEventListener("alasql-chat-message-added", handler as EventListener); - return () => window.removeEventListener("alasql-chat-message-added", handler as EventListener); - }, [currentRoom?.id, config.userId]); - - // Enhanced room management functions - const createRoomFromRequest = useCallback(async (request: CreateRoomRequest): Promise => { - const manager = managerRef.current; - if (!manager) return null; - - try { - const result = await manager.createRoomFromRequest(request, config.userId); - if (result.success) { - console.log('[useChatManager] ๐Ÿ  Created room from request:', result.data); - return result.data!; - } - setError(result.error || 'Failed to create room'); - return null; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to create room'); - return null; - } - }, [config.userId]); - - const getAvailableRooms = useCallback(async (filter?: RoomListFilter): Promise => { - const manager = managerRef.current; - if (!manager) return []; - - try { - const result = await manager.getAvailableRooms(config.userId, filter); - if (result.success) { - return result.data!; - } - setError(result.error || 'Failed to get available rooms'); - return []; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to get available rooms'); - return []; - } - }, [config.userId]); - - const joinRoom = useCallback(async (roomId: string): Promise => { - const manager = managerRef.current; - if (!manager) return false; - - try { - const result = await manager.joinRoom({ - roomId, - userId: config.userId, - userName: config.userName - }); - if (result.success) { - console.log('[useChatManager] ๐Ÿšช Joined room:', result.data!.name); - // Switch to the joined room - await setCurrentRoomById(roomId); - return true; - } - setError(result.error || 'Failed to join room'); - return false; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to join room'); - return false; - } - }, [config.userId, config.userName, setCurrentRoomById]); - - const leaveRoom = useCallback(async (roomId: string): Promise => { - const manager = managerRef.current; - if (!manager) return false; - - try { - const result = await manager.leaveRoom(roomId, config.userId); - if (result.success) { - console.log('[useChatManager] ๐Ÿšช Left room:', roomId); - // If we left the current room, switch to a default room - if (currentRoom?.id === roomId) { - await setCurrentRoomById(config.roomId); // Fall back to default room - } - return true; - } - setError(result.error || 'Failed to leave room'); - return false; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to leave room'); - return false; - } - }, [config.userId, config.roomId, currentRoom?.id, setCurrentRoomById]); - - const canUserJoinRoom = useCallback(async (roomId: string): Promise => { - const manager = managerRef.current; - if (!manager) return false; - - try { - const result = await manager.canUserJoinRoom(roomId, config.userId); - return result.success ? result.data! : false; - } catch (error) { - return false; - } - }, [config.userId]); - - const getRoomParticipants = useCallback(async (roomId: string): Promise> => { - const manager = managerRef.current; - if (!manager) return []; - - try { - const result = await manager.getRoomParticipants(roomId); - if (result.success) { - return result.data!; - } - setError(result.error || 'Failed to get room participants'); - return []; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to get room participants'); - return []; - } - }, []); - - return { - // Connection state - isConnected, - connectionState, - isLoading, - error, - - // Current room data - currentRoom, - messages, - typingUsers, - - // Operations - sendMessage, - loadMoreMessages, - refreshMessages, - startTyping, - stopTyping, - - // Room management - setCurrentRoom: setCurrentRoomById, - createRoom, - - // Enhanced room management - createRoomFromRequest, - getAvailableRooms, - joinRoom, - leaveRoom, - canUserJoinRoom, - getRoomParticipants, - - // Manager access - manager: managerRef.current, - - // Cleanup - disconnect, - }; -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.ts deleted file mode 100644 index 62ee57d893..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// ChatBoxComponent Module Exports -// Provides clean access to all chat component functionality - -// Main component -export { ChatBoxComp } from './chatBoxComp'; - -// Data layer -export type { ChatDataProvider } from './providers/ChatDataProvider'; -export { BaseChatDataProvider } from './providers/ChatDataProvider'; -export { ALASqlProvider } from './providers/ALASqlProvider'; -export { YjsPluvProvider } from './providers/YjsPluvProvider'; -// export type { YjsPluvProviderConfig } from './providers/YjsPluvProvider'; - -// Management layer -export { HybridChatManager } from './managers/HybridChatManager'; -export type { HybridChatManagerConfig } from './managers/HybridChatManager'; - -// React hooks -export { useChatManager } from './hooks/useChatManager'; -export type { UseChatManagerConfig } from './hooks/useChatManager'; - -// Types and utilities -export * from './types/chatDataTypes'; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.tsx deleted file mode 100644 index 52de413b71..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ChatBoxComp } from "./chatBoxComp"; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/managers/HybridChatManager.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/managers/HybridChatManager.ts deleted file mode 100644 index 11cb44cdf3..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/managers/HybridChatManager.ts +++ /dev/null @@ -1,668 +0,0 @@ -// Hybrid Chat Manager -// Coordinates between local (ALASql) and collaborative (Yjs+Pluv.io) providers -// Provides a unified interface for chat components - -import { ChatDataProvider, UnsubscribeFunction } from '../providers/ChatDataProvider'; -import { ALASqlProvider } from '../providers/ALASqlProvider'; -import { YjsPluvProvider } from '../providers/YjsPluvProvider'; -import { - UnifiedMessage, - UnifiedRoom, - UserPresence, - TypingState, - ConnectionConfig, - ConnectionState, - ChatEvent, - OperationResult, - ChatDataError, - ChatErrorCodes, - CreateRoomRequest, - JoinRoomRequest, - RoomMembershipUpdate, - RoomListFilter -} from '../types/chatDataTypes'; - -// Global provider cache to share instances across components with same applicationId -const globalProviderCache = new Map(); - -// Function to get or create shared ALASqlProvider for applicationId -function getSharedALASqlProvider(applicationId: string): ALASqlProvider { - const cacheKey = `alasql_${applicationId}`; - - if (!globalProviderCache.has(cacheKey)) { - console.log(`[HybridChatManager] ๐Ÿ—๏ธ Creating new shared ALASqlProvider for applicationId: ${applicationId}`); - globalProviderCache.set(cacheKey, new ALASqlProvider()); - } else { - console.log(`[HybridChatManager] โ™ป๏ธ Reusing existing ALASqlProvider for applicationId: ${applicationId}`); - } - - return globalProviderCache.get(cacheKey)!; -} - -// Manager configuration -export interface HybridChatManagerConfig { - mode: 'local' | 'collaborative' | 'hybrid'; - userId: string; - userName: string; - applicationId: string; - - // Local provider config - local?: { - dbName?: string; - tableName?: string; - }; - - // Collaborative provider config - collaborative?: { - serverUrl: string; - roomId: string; - authToken?: string; - autoConnect?: boolean; - }; - - // Fallback behavior - fallbackToLocal?: boolean; - autoReconnect?: boolean; - reconnectDelay?: number; -} - -// Events emitted by the manager -export type ManagerEventType = 'provider_switched' | 'sync_started' | 'sync_completed' | 'sync_failed' | 'connection_changed'; - -export interface ManagerEvent { - type: ManagerEventType; - provider?: string; - error?: string; - timestamp: number; -} - -export type ManagerEventCallback = (event: ManagerEvent) => void; - -export class HybridChatManager { - private config: HybridChatManagerConfig; - private primaryProvider!: ChatDataProvider; // Use definite assignment assertion - private secondaryProvider?: ChatDataProvider; - private currentMode: 'local' | 'collaborative' | 'hybrid'; - - // Event management - private managerEventCallbacks: ManagerEventCallback[] = []; - private subscriptions: Map = new Map(); - - // Reconnection handling - private reconnectTimer?: NodeJS.Timeout; - private reconnectAttempts = 0; - private maxReconnectAttempts = 5; - - constructor(config: HybridChatManagerConfig) { - this.config = config; - this.currentMode = config.mode === 'collaborative' ? 'collaborative' : - config.mode === 'hybrid' ? 'hybrid' : 'local'; - - // Initialize providers based on mode - this.initializeProviders(); - this.setupProviderListeners(); - } - - private initializeProviders(): void { - // Use shared ALASqlProvider for same applicationId to enable cross-component room discovery - this.primaryProvider = getSharedALASqlProvider(this.config.applicationId); - - // Initialize collaborative provider if configured - if (this.config.mode === 'collaborative' || this.config.mode === 'hybrid') { - // Initialize YjsPluvProvider for collaborative features - if (this.config.collaborative) { - try { - this.secondaryProvider = new YjsPluvProvider(); - - // Switch primary provider for collaborative mode - if (this.config.mode === 'collaborative') { - [this.primaryProvider, this.secondaryProvider] = [this.secondaryProvider, this.primaryProvider]; - this.currentMode = 'collaborative'; - } - } catch (error) { - console.error('[HybridChatManager] โŒ FAILED to initialize collaborative provider:', error); - - if (this.config.fallbackToLocal !== false) { - console.log('[HybridChatManager] Falling back to local mode'); - this.currentMode = 'local'; - } else { - throw error; - } - } - } - } - } - - private setupProviderListeners(): void { - // Monitor primary provider connection - if (this.primaryProvider.subscribeToConnection) { - const connectionUnsub = this.primaryProvider.subscribeToConnection((state: ConnectionState) => { - // Handle connection failures for collaborative provider - if (this.currentMode === 'collaborative' && state === 'failed' && this.secondaryProvider) { - this.handleProviderFailure(); - } - - this.emitManagerEvent({ - type: 'connection_changed', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - }); - - this.addSubscription('connection', connectionUnsub); - } - } - - private async handleProviderFailure(): Promise { - if (!this.config.fallbackToLocal || !this.secondaryProvider) { - return; - } - - try { - // Switch providers - [this.primaryProvider, this.secondaryProvider] = [this.secondaryProvider, this.primaryProvider]; - this.currentMode = 'local'; - - // Emit switch event - this.emitManagerEvent({ - type: 'provider_switched', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - - // Start reconnection attempts for collaborative provider - this.startReconnectionTimer(); - - } catch (error) { - console.error('[HybridChatManager] Failed to switch providers:', error); - - this.emitManagerEvent({ - type: 'sync_failed', - error: error instanceof Error ? error.message : 'Provider switch failed', - timestamp: Date.now() - }); - } - } - - private startReconnectionTimer(): void { - if (this.reconnectTimer || !this.config.autoReconnect) { - return; - } - - const delay = Math.min( - this.config.reconnectDelay || 1000 * Math.pow(2, this.reconnectAttempts), - 30000 // Max 30 seconds - ); - - this.reconnectTimer = setTimeout(async () => { - this.reconnectTimer = undefined; - - if (this.reconnectAttempts >= this.maxReconnectAttempts) { - return; - } - - this.reconnectAttempts++; - - try { - // Try to reconnect the collaborative provider - if (this.secondaryProvider && this.secondaryProvider.name === 'YjsPluvProvider') { - const result = await this.secondaryProvider.connect({ - mode: 'collaborative', - userId: this.config.userId, - userName: this.config.userName, - realtime: this.config.collaborative || { - serverUrl: '', - roomId: '', - authToken: undefined - } - }); - - if (result.success) { - // Switch back to collaborative provider - [this.primaryProvider, this.secondaryProvider] = [this.secondaryProvider, this.primaryProvider]; - this.currentMode = 'collaborative'; - this.reconnectAttempts = 0; - - this.emitManagerEvent({ - type: 'provider_switched', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - } else { - this.startReconnectionTimer(); // Try again - } - } - } catch (error) { - this.startReconnectionTimer(); // Try again - } - }, delay); - } - - // Initialization - async initialize(): Promise> { - try { - this.emitManagerEvent({ - type: 'sync_started', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - - // Prepare connection config - const connectionConfig = { - mode: this.currentMode, - userId: this.config.userId, - userName: this.config.userName, - alasql: { - dbName: this.config.local?.dbName || `ChatDB_${this.config.userId}`, - tableName: this.config.local?.tableName - }, - realtime: this.config.collaborative ? { - roomId: this.config.collaborative.roomId, - serverUrl: this.config.collaborative.serverUrl, - authToken: this.config.collaborative.authToken - } : undefined - }; - - const result = await this.primaryProvider.connect(connectionConfig); - - if (!result.success) { - throw new Error(result.error || 'Failed to initialize primary provider'); - } - - this.emitManagerEvent({ - type: 'sync_completed', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - - return { success: true, data: undefined, timestamp: Date.now() }; - } catch (error) { - console.error('[HybridChatManager] ๐Ÿ’ฅ Initialization failed:', error); - - this.emitManagerEvent({ - type: 'sync_failed', - error: error instanceof Error ? error.message : 'Initialization failed', - timestamp: Date.now() - }); - - return { - success: false, - error: error instanceof Error ? error.message : 'Initialization failed', - timestamp: Date.now() - }; - } - } - - async disconnect(): Promise> { - try { - // Clear reconnect timer - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = undefined; - } - - // Disconnect all providers - await this.primaryProvider.disconnect(); - if (this.secondaryProvider) { - await this.secondaryProvider.disconnect(); - } - - // Clean up subscriptions - this.subscriptions.forEach(subs => subs.forEach(unsub => unsub())); - this.subscriptions.clear(); - - return { success: true, timestamp: Date.now() }; - } catch (error) { - return this.handleError(error, 'disconnect'); - } - } - - // Provider management - private getActiveProvider(): ChatDataProvider { - // Always return primary provider for now - // TODO: Implement provider switching logic - return this.primaryProvider; - } - - private addSubscription(key: string, unsubscribe: UnsubscribeFunction): void { - if (!this.subscriptions.has(key)) { - this.subscriptions.set(key, []); - } - this.subscriptions.get(key)!.push(unsubscribe); - } - - private unsubscribeAll(key: string): void { - const unsubs = this.subscriptions.get(key); - if (unsubs) { - unsubs.forEach(unsub => unsub()); - this.subscriptions.delete(key); - } - } - - // Room operations (delegated to active provider) - async createRoom(room: Omit): Promise> { - return this.getActiveProvider().createRoom(room); - } - - async getRooms(userId?: string): Promise> { - return this.getActiveProvider().getRooms(userId); - } - - async getRoom(roomId: string): Promise> { - return this.getActiveProvider().getRoom(roomId); - } - - async getRoomByName(name: string): Promise> { - return this.getActiveProvider().getRoomByName(name); - } - - async updateRoom(roomId: string, updates: Partial): Promise> { - return this.getActiveProvider().updateRoom(roomId, updates); - } - - async deleteRoom(roomId: string): Promise> { - return this.getActiveProvider().deleteRoom(roomId); - } - - // Enhanced room management operations (delegated to active provider) - async createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise> { - console.log('[HybridChatManager] ๐Ÿ  Creating room from request:', request); - return this.getActiveProvider().createRoomFromRequest(request, creatorId); - } - - async getAvailableRooms(userId: string, filter?: RoomListFilter): Promise> { - console.log('[HybridChatManager] ๐Ÿ” Getting available rooms for user:', userId, 'filter:', filter); - return this.getActiveProvider().getAvailableRooms(userId, filter); - } - - async joinRoom(request: JoinRoomRequest): Promise> { - console.log('[HybridChatManager] ๐Ÿšช User joining room:', request); - return this.getActiveProvider().joinRoom(request); - } - - async leaveRoom(roomId: string, userId: string): Promise> { - console.log('[HybridChatManager] ๐Ÿšช User leaving room:', { roomId, userId }); - return this.getActiveProvider().leaveRoom(roomId, userId); - } - - async updateRoomMembership(update: RoomMembershipUpdate): Promise> { - console.log('[HybridChatManager] ๐Ÿ‘ฅ Updating room membership:', update); - return this.getActiveProvider().updateRoomMembership(update); - } - - async canUserJoinRoom(roomId: string, userId: string): Promise> { - console.log('[HybridChatManager] ๐Ÿ” Checking if user can join room:', { roomId, userId }); - return this.getActiveProvider().canUserJoinRoom(roomId, userId); - } - - async getRoomParticipants(roomId: string): Promise>> { - console.log('[HybridChatManager] ๐Ÿ‘ฅ Getting room participants:', { roomId }); - - try { - // First get the room to access participants - const roomResult = await this.getRoom(roomId); - if (!roomResult.success || !roomResult.data) { - return { - success: false, - error: roomResult.error || 'Room not found', - timestamp: Date.now() - }; - } - - const room = roomResult.data; - const participants = room.participants || []; - - // Get participant details by looking at recent messages to extract user names - const messagesResult = await this.getMessages(roomId, 100); // Get recent messages - if (!messagesResult.success) { - // If we can't get messages, return participants with just IDs - return { - success: true, - data: participants.map(id => ({ id, name: id })), // Fallback to ID as name - timestamp: Date.now() - }; - } - - // Create a map of userId -> userName from messages - const userMap = new Map(); - messagesResult.data?.forEach(message => { - if (message.authorId && message.authorName) { - userMap.set(message.authorId, message.authorName); - } - }); - - // Build participant list with names - const participantsWithNames = participants.map(participantId => ({ - id: participantId, - name: userMap.get(participantId) || participantId // Fallback to ID if name not found - })); - - return { - success: true, - data: participantsWithNames, - timestamp: Date.now() - }; - - } catch (error) { - console.error('[HybridChatManager] Error getting room participants:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get room participants', - timestamp: Date.now() - }; - } - } - - // Message operations (delegated to active provider) - async sendMessage(message: Omit): Promise> { - const activeProvider = this.getActiveProvider(); - - try { - const result = await activeProvider.sendMessage(message); - return result; - } catch (error) { - console.error('[HybridChatManager] Error in sendMessage:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to send message', - timestamp: Date.now() - }; - } - } - - async getMessages(roomId: string, limit?: number, before?: number): Promise> { - return this.getActiveProvider().getMessages(roomId, limit, before); - } - - async getMessage(messageId: string): Promise> { - return this.getActiveProvider().getMessage(messageId); - } - - async updateMessage(messageId: string, updates: Partial): Promise> { - return this.getActiveProvider().updateMessage(messageId, updates); - } - - async deleteMessage(messageId: string): Promise> { - return this.getActiveProvider().deleteMessage(messageId); - } - - // Presence operations (delegated to active provider) - async updatePresence(presence: Partial): Promise> { - return this.getActiveProvider().updatePresence(presence); - } - - async getPresence(roomId: string): Promise> { - return this.getActiveProvider().getPresence(roomId); - } - - // Typing operations (delegated to active provider) - async startTyping(roomId: string): Promise> { - return this.getActiveProvider().startTyping(roomId); - } - - async stopTyping(roomId: string): Promise> { - return this.getActiveProvider().stopTyping(roomId); - } - - // Subscription management (with cleanup tracking) - subscribeToRoom(roomId: string, callback: (event: ChatEvent) => void): UnsubscribeFunction { - const unsubscribe = this.getActiveProvider().subscribeToRoom(roomId, callback); - - // Track subscription for cleanup - if (!this.subscriptions.has(roomId)) { - this.subscriptions.set(roomId, []); - } - this.subscriptions.get(roomId)!.push(unsubscribe); - - return () => { - unsubscribe(); - const subs = this.subscriptions.get(roomId); - if (subs) { - const index = subs.indexOf(unsubscribe); - if (index > -1) { - subs.splice(index, 1); - } - if (subs.length === 0) { - this.subscriptions.delete(roomId); - } - } - }; - } - - subscribeToPresence(roomId: string, callback: (users: UserPresence[]) => void): UnsubscribeFunction { - return this.getActiveProvider().subscribeToPresence(roomId, callback); - } - - subscribeToTyping(roomId: string, callback: (typingUsers: TypingState[]) => void): UnsubscribeFunction { - return this.getActiveProvider().subscribeToTyping(roomId, callback); - } - - subscribeToConnection(callback: (state: ConnectionState) => void): UnsubscribeFunction { - return this.getActiveProvider().subscribeToConnection(callback); - } - - // Manager events - subscribeToManagerEvents(callback: ManagerEventCallback): UnsubscribeFunction { - this.managerEventCallbacks.push(callback); - - return () => { - const index = this.managerEventCallbacks.indexOf(callback); - if (index > -1) { - this.managerEventCallbacks.splice(index, 1); - } - }; - } - - private emitManagerEvent(event: ManagerEvent): void { - this.managerEventCallbacks.forEach(callback => { - try { - callback(event); - } catch (error) { - console.error('Error in manager event callback:', error); - } - }); - } - - // Utility operations - async clearRoomData(roomId: string): Promise> { - return this.getActiveProvider().clearRoomData(roomId); - } - - async exportData(): Promise> { - return this.getActiveProvider().exportData(); - } - - async importData(data: any): Promise> { - return this.getActiveProvider().importData(data); - } - - async healthCheck(): Promise> { - const primaryHealth = await this.getActiveProvider().healthCheck(); - - return { - success: true, - data: { - status: primaryHealth.data?.status || 'unknown', - details: { - mode: this.currentMode, - provider: this.getActiveProvider().name, - reconnectAttempts: this.reconnectAttempts, - primary: primaryHealth.data, - // TODO: Add secondary provider health when available - } - }, - timestamp: Date.now(), - }; - } - - // Getters - getConnectionState(): ConnectionState { - return this.getActiveProvider().getConnectionState(); - } - - isConnected(): boolean { - return this.getActiveProvider().isConnected(); - } - - getCurrentMode(): 'local' | 'collaborative' | 'hybrid' { - return this.currentMode; - } - - getConfig(): HybridChatManagerConfig { - return { ...this.config }; - } - - // Future methods for provider switching - async switchToCollaborativeMode(): Promise> { - // TODO: Implement when Yjs provider is ready - return { - success: false, - error: 'Collaborative mode not implemented yet', - timestamp: Date.now(), - }; - } - - async switchToLocalMode(): Promise> { - if (this.currentMode === 'local') { - return { success: true, timestamp: Date.now() }; - } - - this.currentMode = 'local'; - this.emitManagerEvent({ - type: 'provider_switched', - provider: 'local', - timestamp: Date.now() - }); - - return { success: true, timestamp: Date.now() }; - } - - // Sync operations (for future hybrid mode) - async syncToCollaborative(): Promise> { - // TODO: Implement data sync between providers - return { - success: false, - error: 'Sync not implemented yet', - timestamp: Date.now(), - }; - } - - // Error handling - private handleError(error: any, operation: string): OperationResult { - console.error(`HybridChatManager error in ${operation}:`, error); - - if (error instanceof ChatDataError) { - return { - success: false, - error: error.message, - timestamp: Date.now(), - }; - } - - return { - success: false, - error: error?.message || `Unknown error in ${operation}`, - timestamp: Date.now(), - }; - } -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ALASqlProvider.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ALASqlProvider.ts deleted file mode 100644 index a0afc568de..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ALASqlProvider.ts +++ /dev/null @@ -1,631 +0,0 @@ -// ALASql provider implementation -// Wraps existing ALASql functionality to work with our unified interface - -import alasql from "alasql"; -import { BaseChatDataProvider } from './ChatDataProvider'; -import { - UnifiedMessage, - UnifiedRoom, - UserPresence, - ConnectionConfig, - OperationResult, - DataTransformUtils, - ChatDataError, - ChatErrorCodes, - CreateRoomRequest, - JoinRoomRequest, - RoomMembershipUpdate, - RoomListFilter -} from '../types/chatDataTypes'; - -interface ALASqlMessage { - id: string; - threadId: string; - role: string; - text: string; - timestamp: number; -} - -interface ALASqlThread { - threadId: string; - status: string; - title: string; - createdAt: number; - updatedAt: number; -} - -export class ALASqlProvider extends BaseChatDataProvider { - public readonly name = 'ALASqlProvider'; - public readonly version = '1.0.0'; - - private initialized = false; - private dbName = 'ChatDB'; - private threadsTable = 'threads'; - private messagesTable = 'messages'; - private presenceTable = 'presence'; - - constructor() { - super(); - } - - async connect(config: ConnectionConfig): Promise> { - try { - this.setConnectionState('connecting'); - this.config = config; - if (config.alasql?.dbName) { - this.dbName = config.alasql.dbName; - } - alasql.options.autocommit = true; - await this.initializeDatabase(); - this.initialized = true; - this.setConnectionState('connected'); - return this.createSuccessResult(); - } catch (error) { - this.setConnectionState('failed'); - return this.handleError(error, 'connect'); - } - } - - async disconnect(): Promise> { - try { - this.setConnectionState('disconnected'); - this.initialized = false; - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'disconnect'); - } - } - - private async initializeDatabase(): Promise { - try { - 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 ${this.threadsTable} ( - threadId STRING PRIMARY KEY, - status STRING, - title STRING, - createdAt NUMBER, - updatedAt NUMBER - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS ${this.messagesTable} ( - id STRING PRIMARY KEY, - threadId STRING, - role STRING, - text STRING, - timestamp NUMBER - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS ${this.presenceTable} ( - userId STRING PRIMARY KEY, - userName STRING, - status STRING, - lastSeen NUMBER, - currentRoom STRING - ) - `); - } catch (error) { - throw new ChatDataError( - 'Failed to initialize ALASql database', - ChatErrorCodes.STORAGE_ERROR, - error - ); - } - } - - private async ensureInitialized(): Promise { - if (!this.initialized) { - throw new ChatDataError( - 'Provider not initialized. Call connect() first.', - ChatErrorCodes.CONNECTION_FAILED - ); - } - } - - async createRoom(room: Omit): Promise> { - try { - await this.ensureInitialized(); - const now = Date.now(); - const roomId = `room_${room.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`; - const newRoom: UnifiedRoom = { - id: roomId, - createdAt: now, - updatedAt: now, - ...room, - }; - const alaSqlThread: ALASqlThread = DataTransformUtils.toLegacyThread(newRoom); - await alasql.promise(` - INSERT INTO ${this.threadsTable} VALUES (?, ?, ?, ?, ?) - `, [alaSqlThread.threadId, alaSqlThread.status, alaSqlThread.title, alaSqlThread.createdAt, alaSqlThread.updatedAt]); - return this.createSuccessResult(newRoom); - } catch (error) { - return this.handleError(error, 'createRoom'); - } - } - - async getRooms(userId?: string): Promise> { - try { - await this.ensureInitialized(); - const result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} ORDER BY updatedAt DESC - `) as ALASqlThread[]; - const rooms = (Array.isArray(result) ? result : []).map(thread => - DataTransformUtils.fromLegacyThread(thread) - ); - return this.createSuccessResult(rooms); - } catch (error) { - return this.handleError(error, 'getRooms'); - } - } - - async getRoom(roomId: string): Promise> { - try { - await this.ensureInitialized(); - const result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} WHERE threadId = ? - `, [roomId]) as ALASqlThread[]; - if (!result || result.length === 0) { - throw new ChatDataError( - `Room with id ${roomId} not found`, - ChatErrorCodes.ROOM_NOT_FOUND - ); - } - const room = DataTransformUtils.fromLegacyThread(result[0]); - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'getRoom'); - } - } - - async getRoomByName(name: string): Promise> { - try { - await this.ensureInitialized(); - let result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} WHERE title = ? - `, [name]) as ALASqlThread[]; - if (!result || result.length === 0) { - result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} WHERE title = ? - `, [`Chat Room ${name}`]) as ALASqlThread[]; - } - if (!result || result.length === 0) { - result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} WHERE title LIKE ? - `, [`%${name}%`]) as ALASqlThread[]; - } - if (!result || result.length === 0) { - throw new ChatDataError( - `Room with name ${name} not found`, - ChatErrorCodes.ROOM_NOT_FOUND - ); - } - const room = DataTransformUtils.fromLegacyThread(result[0]); - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'getRoomByName'); - } - } - - async updateRoom(roomId: string, updates: Partial): Promise> { - try { - await this.ensureInitialized(); - const existingResult = await this.getRoom(roomId); - if (!existingResult.success) { - return existingResult; - } - const updatedRoom: UnifiedRoom = { - ...existingResult.data!, - ...updates, - updatedAt: Date.now(), - }; - const alaSqlThread = DataTransformUtils.toLegacyThread(updatedRoom); - await alasql.promise(` - UPDATE ${this.threadsTable} - SET status = ?, title = ?, updatedAt = ? - WHERE threadId = ? - `, [alaSqlThread.status, alaSqlThread.title, alaSqlThread.updatedAt, roomId]); - return this.createSuccessResult(updatedRoom); - } catch (error) { - return this.handleError(error, 'updateRoom'); - } - } - - async deleteRoom(roomId: string): Promise> { - try { - await this.ensureInitialized(); - await alasql.promise(`DELETE FROM ${this.messagesTable} WHERE threadId = ?`, [roomId]); - await alasql.promise(`DELETE FROM ${this.threadsTable} WHERE threadId = ?`, [roomId]); - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'deleteRoom'); - } - } - - // Enhanced room management operations - async createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise> { - try { - await this.ensureInitialized(); - const roomId = this.generateId(); - const now = Date.now(); - - const newRoom: UnifiedRoom = { - id: roomId, - name: request.name, - type: request.type, - participants: [creatorId], - admins: [creatorId], - creator: creatorId, - description: request.description, - maxParticipants: request.maxParticipants, - isActive: true, - lastActivity: now, - createdAt: now, - updatedAt: now, - }; - - // Convert to ALASql format and save - const legacyThread = DataTransformUtils.toLegacyThread(newRoom); - await alasql.promise(` - INSERT INTO ${this.threadsTable} VALUES (?, ?, ?, ?, ?) - `, [legacyThread.threadId, legacyThread.status, legacyThread.title, legacyThread.createdAt, legacyThread.updatedAt]); - - console.log('[ALASqlProvider] ๐Ÿ  Created room from request:', newRoom); - return this.createSuccessResult(newRoom); - } catch (error) { - return this.handleError(error, 'createRoomFromRequest'); - } - } - - async getAvailableRooms(userId: string, filter?: RoomListFilter): Promise> { - try { - await this.ensureInitialized(); - // For ALASql (local storage), all rooms are "available" to the user - // since there's no real multi-user separation - let query = `SELECT * FROM ${this.threadsTable}`; - const conditions: string[] = []; - const params: any[] = []; - - if (filter?.type) { - // Note: ALASql threads don't have type, so we'll default to private - conditions.push('status = ?'); - params.push('regular'); - } - - if (conditions.length > 0) { - query += ' WHERE ' + conditions.join(' AND '); - } - - query += ' ORDER BY updatedAt DESC'; - - const threads = await alasql.promise(query, params) as ALASqlThread[]; - const rooms = threads.map(thread => DataTransformUtils.fromLegacyThread(thread)); - - return this.createSuccessResult(rooms); - } catch (error) { - return this.handleError(error, 'getAvailableRooms'); - } - } - - async joinRoom(request: JoinRoomRequest): Promise> { - try { - // For ALASql (local), joining is just getting the room - // since there's no real multi-user management - const roomResult = await this.getRoom(request.roomId); - if (!roomResult.success) { - return roomResult; - } - - console.log(`[ALASqlProvider] ๐Ÿšช User ${request.userName} "joined" room ${roomResult.data!.name} (local only)`); - return roomResult; - } catch (error) { - return this.handleError(error, 'joinRoom'); - } - } - - async leaveRoom(roomId: string, userId: string): Promise> { - try { - // For ALASql (local), leaving is a no-op since there's no real membership - console.log(`[ALASqlProvider] ๐Ÿšช User ${userId} "left" room ${roomId} (local only)`); - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'leaveRoom'); - } - } - - async updateRoomMembership(update: RoomMembershipUpdate): Promise> { - try { - // For ALASql (local), membership updates are no-ops - // Just return the room as-is - const roomResult = await this.getRoom(update.roomId); - if (!roomResult.success) { - return roomResult; - } - - console.log(`[ALASqlProvider] ๐Ÿ‘ฅ Membership update "${update.action}" for user ${update.userId} (local only - no effect)`); - return roomResult; - } catch (error) { - return this.handleError(error, 'updateRoomMembership'); - } - } - - async canUserJoinRoom(roomId: string, userId: string): Promise> { - try { - // For ALASql (local), users can always "join" any room that exists - const roomResult = await this.getRoom(roomId); - return this.createSuccessResult(roomResult.success); - } catch (error) { - return this.handleError(error, 'canUserJoinRoom'); - } - } - - async sendMessage(message: Omit): Promise> { - try { - await this.ensureInitialized(); - const newMessage: UnifiedMessage = { - id: this.generateId(), - timestamp: Date.now(), - status: 'synced', - ...message, - }; - const alaSqlMessage = DataTransformUtils.toLegacyMessage(newMessage); - alaSqlMessage.threadId = newMessage.roomId; - await alasql.promise(` - INSERT INTO ${this.messagesTable} VALUES (?, ?, ?, ?, ?) - `, [alaSqlMessage.id, alaSqlMessage.threadId, alaSqlMessage.role, alaSqlMessage.text, alaSqlMessage.timestamp]); - this.notifyRoomSubscribers(message.roomId, { - type: 'message_added', - roomId: message.roomId, - userId: message.authorId, - data: newMessage, - timestamp: Date.now(), - }); - if (typeof window !== 'undefined' && window.dispatchEvent) { - try { - window.dispatchEvent( - new CustomEvent('alasql-chat-message-added', { - detail: { roomId: message.roomId, message: newMessage }, - }), - ); - } catch (e) { - /* Ignore if CustomEvent is not supported */ - } - } - return this.createSuccessResult(newMessage); - } catch (error) { - return this.handleError(error, 'sendMessage'); - } - } - - async getMessages(roomId: string, limit = 50, before?: number): Promise> { - try { - await this.ensureInitialized(); - let query = ` - SELECT * FROM ${this.messagesTable} - WHERE threadId = ? - `; - const params: any[] = [roomId]; - if (before) { - query += ` AND timestamp < ?`; - params.push(before); - } - query += ` ORDER BY timestamp DESC LIMIT ?`; - params.push(limit); - const result = await alasql.promise(query, params) as ALASqlMessage[]; - const messages = (Array.isArray(result) ? result : []).map(alaSqlMsg => - DataTransformUtils.fromLegacyMessage( - alaSqlMsg, - roomId, - alaSqlMsg.role === 'assistant' ? 'assistant' : this.config?.userId || 'unknown', - alaSqlMsg.role === 'assistant' ? 'Assistant' : this.config?.userName || 'User' - ) - ).reverse(); - return this.createSuccessResult(messages); - } catch (error) { - return this.handleError(error, 'getMessages'); - } - } - - async getMessage(messageId: string): Promise> { - try { - await this.ensureInitialized(); - const result = await alasql.promise(` - SELECT * FROM ${this.messagesTable} WHERE id = ? - `, [messageId]) as ALASqlMessage[]; - if (!result || result.length === 0) { - throw new ChatDataError( - `Message with id ${messageId} not found`, - ChatErrorCodes.MESSAGE_NOT_FOUND - ); - } - const alaSqlMsg = result[0]; - const message = DataTransformUtils.fromLegacyMessage( - alaSqlMsg, - alaSqlMsg.threadId, - alaSqlMsg.role === 'assistant' ? 'assistant' : this.config?.userId || 'unknown', - alaSqlMsg.role === 'assistant' ? 'Assistant' : this.config?.userName || 'User' - ); - return this.createSuccessResult(message); - } catch (error) { - return this.handleError(error, 'getMessage'); - } - } - - async updateMessage(messageId: string, updates: Partial): Promise> { - try { - await this.ensureInitialized(); - const existingResult = await this.getMessage(messageId); - if (!existingResult.success) { - return existingResult; - } - const updatedMessage: UnifiedMessage = { - ...existingResult.data!, - ...updates, - }; - const alaSqlMessage = DataTransformUtils.toLegacyMessage(updatedMessage); - await alasql.promise(` - UPDATE ${this.messagesTable} - SET text = ?, timestamp = ? - WHERE id = ? - `, [alaSqlMessage.text, alaSqlMessage.timestamp, messageId]); - this.notifyRoomSubscribers(updatedMessage.roomId, { - type: 'message_updated', - roomId: updatedMessage.roomId, - userId: updatedMessage.authorId, - data: updatedMessage, - timestamp: Date.now(), - }); - return this.createSuccessResult(updatedMessage); - } catch (error) { - return this.handleError(error, 'updateMessage'); - } - } - - async deleteMessage(messageId: string): Promise> { - try { - await this.ensureInitialized(); - const messageResult = await this.getMessage(messageId); - await alasql.promise(`DELETE FROM ${this.messagesTable} WHERE id = ?`, [messageId]); - if (messageResult.success) { - this.notifyRoomSubscribers(messageResult.data!.roomId, { - type: 'message_deleted', - roomId: messageResult.data!.roomId, - userId: messageResult.data!.authorId, - data: { messageId }, - timestamp: Date.now(), - }); - } - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'deleteMessage'); - } - } - - async updatePresence(presence: Partial): Promise> { - try { - await this.ensureInitialized(); - if (!presence.userId) { - throw new ChatDataError('UserId is required for presence update', ChatErrorCodes.VALIDATION_ERROR); - } - const now = Date.now(); - await alasql.promise(` - INSERT OR REPLACE INTO ${this.presenceTable} VALUES (?, ?, ?, ?, ?) - `, [ - presence.userId, - presence.userName || 'Unknown', - presence.status || 'online', - presence.lastSeen || now, - presence.currentRoom || null - ]); - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'updatePresence'); - } - } - - async getPresence(roomId: string): Promise> { - try { - await this.ensureInitialized(); - const result = await alasql.promise(` - SELECT * FROM ${this.presenceTable} WHERE currentRoom = ? - `, [roomId]) as any[]; - const presence = (Array.isArray(result) ? result : []).map(row => ({ - userId: row.userId, - userName: row.userName, - status: row.status, - lastSeen: row.lastSeen, - currentRoom: row.currentRoom, - })); - return this.createSuccessResult(presence); - } catch (error) { - return this.handleError(error, 'getPresence'); - } - } - - async startTyping(roomId: string): Promise> { - return this.createSuccessResult(); - } - - async stopTyping(roomId: string): Promise> { - return this.createSuccessResult(); - } - - async clearRoomData(roomId: string): Promise> { - try { - await this.ensureInitialized(); - await alasql.promise(`DELETE FROM ${this.messagesTable} WHERE threadId = ?`, [roomId]); - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'clearRoomData'); - } - } - - async exportData(): Promise> { - try { - await this.ensureInitialized(); - const threads = await alasql.promise(`SELECT * FROM ${this.threadsTable}`) as ALASqlThread[]; - const messages = await alasql.promise(`SELECT * FROM ${this.messagesTable}`) as ALASqlMessage[]; - return this.createSuccessResult({ - threads: Array.isArray(threads) ? threads : [], - messages: Array.isArray(messages) ? messages : [], - exportedAt: Date.now(), - provider: this.name, - version: this.version, - }); - } catch (error) { - return this.handleError(error, 'exportData'); - } - } - - async importData(data: any): Promise> { - try { - await this.ensureInitialized(); - if (!data.threads || !data.messages) { - throw new ChatDataError('Invalid import data format', ChatErrorCodes.VALIDATION_ERROR); - } - await alasql.promise(`DELETE FROM ${this.messagesTable}`); - await alasql.promise(`DELETE FROM ${this.threadsTable}`); - for (const thread of data.threads) { - await alasql.promise(` - INSERT INTO ${this.threadsTable} VALUES (?, ?, ?, ?, ?) - `, [thread.threadId, thread.status, thread.title, thread.createdAt, thread.updatedAt]); - } - for (const message of data.messages) { - await alasql.promise(` - INSERT INTO ${this.messagesTable} VALUES (?, ?, ?, ?, ?) - `, [message.id, message.threadId, message.role, message.text, message.timestamp]); - } - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'importData'); - } - } - - async healthCheck(): Promise> { - try { - if (!this.initialized) { - return this.createSuccessResult({ - status: 'disconnected', - details: { message: 'Provider not initialized' } - }); - } - await alasql.promise(`SELECT COUNT(*) as count FROM ${this.threadsTable}`); - return this.createSuccessResult({ - status: 'healthy', - details: { - dbName: this.dbName, - tablesCount: 3, - initialized: this.initialized, - } - }); - } catch (error) { - return this.createSuccessResult({ - status: 'unhealthy', - details: { error: error instanceof Error ? error.message : String(error) } - }); - } - } -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ChatDataProvider.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ChatDataProvider.ts deleted file mode 100644 index acbe0cc45e..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ChatDataProvider.ts +++ /dev/null @@ -1,302 +0,0 @@ -// Core interface for chat data providers -// This abstraction allows us to support both local (ALASql) and collaborative (Yjs) storage - -import { - UnifiedMessage, - UnifiedRoom, - UserPresence, - TypingState, - ConnectionConfig, - ConnectionState, - ChatEvent, - OperationResult, - ChatDataError, - CreateRoomRequest, - JoinRoomRequest, - RoomMembershipUpdate, - RoomListFilter -} from '../types/chatDataTypes'; - -// Callback type for real-time subscriptions -export type ChatEventCallback = (event: ChatEvent) => void; -export type PresenceCallback = (users: UserPresence[]) => void; -export type TypingCallback = (typingUsers: TypingState[]) => void; -export type ConnectionCallback = (state: ConnectionState) => void; - -// Subscription cleanup function -export type UnsubscribeFunction = () => void; - -// Main data provider interface -export interface ChatDataProvider { - // Provider identification - readonly name: string; - readonly version: string; - - // Connection management - connect(config: ConnectionConfig): Promise>; - disconnect(): Promise>; - getConnectionState(): ConnectionState; - isConnected(): boolean; - - // Room/Thread operations - createRoom(room: Omit): Promise>; - getRooms(userId?: string): Promise>; - getRoom(roomId: string): Promise>; - getRoomByName(name: string): Promise>; - updateRoom(roomId: string, updates: Partial): Promise>; - deleteRoom(roomId: string): Promise>; - - // Enhanced room management operations - createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise>; - getAvailableRooms(userId: string, filter?: RoomListFilter): Promise>; - joinRoom(request: JoinRoomRequest): Promise>; - leaveRoom(roomId: string, userId: string): Promise>; - updateRoomMembership(update: RoomMembershipUpdate): Promise>; - canUserJoinRoom(roomId: string, userId: string): Promise>; - - // Message operations - sendMessage(message: Omit): Promise>; - getMessages(roomId: string, limit?: number, before?: number): Promise>; - getMessage(messageId: string): Promise>; - updateMessage(messageId: string, updates: Partial): Promise>; - deleteMessage(messageId: string): Promise>; - - // Real-time subscriptions (for collaborative providers) - subscribeToRoom(roomId: string, callback: ChatEventCallback): UnsubscribeFunction; - subscribeToPresence(roomId: string, callback: PresenceCallback): UnsubscribeFunction; - subscribeToTyping(roomId: string, callback: TypingCallback): UnsubscribeFunction; - subscribeToConnection(callback: ConnectionCallback): UnsubscribeFunction; - - // Presence management - updatePresence(presence: Partial): Promise>; - getPresence(roomId: string): Promise>; - - // Typing indicators - startTyping(roomId: string): Promise>; - stopTyping(roomId: string): Promise>; - - // Utility operations - clearRoomData(roomId: string): Promise>; - exportData(): Promise>; - importData(data: any): Promise>; - - // Health check - healthCheck(): Promise>; -} - -// Base abstract class with common functionality -export abstract class BaseChatDataProvider implements ChatDataProvider { - public abstract readonly name: string; - public abstract readonly version: string; - - protected connectionState: ConnectionState = 'disconnected'; - protected config?: ConnectionConfig; - protected roomSubscriptions: Map = new Map(); - protected presenceSubscriptions: Map = new Map(); - protected typingSubscriptions: Map = new Map(); - protected connectionSubscriptions: ConnectionCallback[] = []; - - // Connection state management - getConnectionState(): ConnectionState { - return this.connectionState; - } - - isConnected(): boolean { - return this.connectionState === 'connected'; - } - - protected setConnectionState(state: ConnectionState): void { - if (this.connectionState !== state) { - this.connectionState = state; - this.notifyConnectionSubscribers(state); - } - } - - // Event subscription management - subscribeToRoom(roomId: string, callback: ChatEventCallback): UnsubscribeFunction { - if (!this.roomSubscriptions.has(roomId)) { - this.roomSubscriptions.set(roomId, []); - } - this.roomSubscriptions.get(roomId)!.push(callback); - - return () => { - const callbacks = this.roomSubscriptions.get(roomId); - if (callbacks) { - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - if (callbacks.length === 0) { - this.roomSubscriptions.delete(roomId); - } - } - }; - } - - subscribeToPresence(roomId: string, callback: PresenceCallback): UnsubscribeFunction { - if (!this.presenceSubscriptions.has(roomId)) { - this.presenceSubscriptions.set(roomId, []); - } - this.presenceSubscriptions.get(roomId)!.push(callback); - - return () => { - const callbacks = this.presenceSubscriptions.get(roomId); - if (callbacks) { - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - if (callbacks.length === 0) { - this.presenceSubscriptions.delete(roomId); - } - } - }; - } - - subscribeToTyping(roomId: string, callback: TypingCallback): UnsubscribeFunction { - if (!this.typingSubscriptions.has(roomId)) { - this.typingSubscriptions.set(roomId, []); - } - this.typingSubscriptions.get(roomId)!.push(callback); - - return () => { - const callbacks = this.typingSubscriptions.get(roomId); - if (callbacks) { - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - if (callbacks.length === 0) { - this.typingSubscriptions.delete(roomId); - } - } - }; - } - - subscribeToConnection(callback: ConnectionCallback): UnsubscribeFunction { - this.connectionSubscriptions.push(callback); - - return () => { - const index = this.connectionSubscriptions.indexOf(callback); - if (index > -1) { - this.connectionSubscriptions.splice(index, 1); - } - }; - } - - // Notify subscribers - protected notifyRoomSubscribers(roomId: string, event: ChatEvent): void { - const callbacks = this.roomSubscriptions.get(roomId); - - if (callbacks) { - callbacks.forEach(callback => { - try { - callback(event); - } catch (error) { - console.error(`Error in chat event callback:`, error); - } - }); - } else { - console.warn(`No subscribers found for room: ${roomId}`); - } - } - - protected notifyPresenceSubscribers(roomId: string, users: UserPresence[]): void { - const callbacks = this.presenceSubscriptions.get(roomId); - if (callbacks) { - callbacks.forEach(callback => { - try { - callback(users); - } catch (error) { - console.error('Error in presence callback:', error); - } - }); - } - } - - protected notifyTypingSubscribers(roomId: string, typingUsers: TypingState[]): void { - const callbacks = this.typingSubscriptions.get(roomId); - if (callbacks) { - callbacks.forEach(callback => { - try { - callback(typingUsers); - } catch (error) { - console.error('Error in typing callback:', error); - } - }); - } - } - - protected notifyConnectionSubscribers(state: ConnectionState): void { - this.connectionSubscriptions.forEach(callback => { - try { - callback(state); - } catch (error) { - console.error('Error in connection callback:', error); - } - }); - } - - // Utility methods - protected generateId(): string { - return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - } - - protected createSuccessResult(data?: T): OperationResult { - return { - success: true, - data, - timestamp: Date.now(), - }; - } - - protected createErrorResult(error: string, details?: any): OperationResult { - return { - success: false, - error, - timestamp: Date.now(), - }; - } - - protected handleError(error: any, operation: string): OperationResult { - console.error(`${this.name} provider error in ${operation}:`, error); - - if (error instanceof ChatDataError) { - return this.createErrorResult(error.message, error.details); - } - - return this.createErrorResult( - error?.message || `Unknown error in ${operation}`, - error - ); - } - - // Abstract methods that must be implemented by concrete providers - public abstract connect(config: ConnectionConfig): Promise>; - public abstract disconnect(): Promise>; - public abstract createRoom(room: Omit): Promise>; - public abstract getRooms(userId?: string): Promise>; - public abstract getRoom(roomId: string): Promise>; - public abstract getRoomByName(name: string): Promise>; - public abstract updateRoom(roomId: string, updates: Partial): Promise>; - public abstract deleteRoom(roomId: string): Promise>; - public abstract createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise>; - public abstract getAvailableRooms(userId: string, filter?: RoomListFilter): Promise>; - public abstract joinRoom(request: JoinRoomRequest): Promise>; - public abstract leaveRoom(roomId: string, userId: string): Promise>; - public abstract updateRoomMembership(update: RoomMembershipUpdate): Promise>; - public abstract canUserJoinRoom(roomId: string, userId: string): Promise>; - public abstract sendMessage(message: Omit): Promise>; - public abstract getMessages(roomId: string, limit?: number, before?: number): Promise>; - public abstract getMessage(messageId: string): Promise>; - public abstract updateMessage(messageId: string, updates: Partial): Promise>; - public abstract deleteMessage(messageId: string): Promise>; - public abstract updatePresence(presence: Partial): Promise>; - public abstract getPresence(roomId: string): Promise>; - public abstract startTyping(roomId: string): Promise>; - public abstract stopTyping(roomId: string): Promise>; - public abstract clearRoomData(roomId: string): Promise>; - public abstract exportData(): Promise>; - public abstract importData(data: any): Promise>; - public abstract healthCheck(): Promise>; -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/YjsPluvProvider.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/YjsPluvProvider.ts deleted file mode 100644 index 144d3caef9..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/YjsPluvProvider.ts +++ /dev/null @@ -1,903 +0,0 @@ -// YjsPluvProvider - Real-time collaborative provider using Yjs + WebSocket -// Implements ChatDataProvider interface for seamless integration - -import { ChatDataProvider, BaseChatDataProvider } from './ChatDataProvider'; -import { - UnifiedMessage, - UnifiedRoom, - UserPresence, - TypingState, - ConnectionConfig, - ConnectionState, - ChatEvent, - OperationResult, - CreateRoomRequest, - JoinRoomRequest, - RoomMembershipUpdate, - RoomListFilter -} from '../types/chatDataTypes'; -import * as Y from 'yjs'; -import { WebsocketProvider } from 'y-websocket'; - -export class YjsPluvProvider extends BaseChatDataProvider implements ChatDataProvider { - public readonly name = 'YjsPluvProvider'; - public readonly version = '1.0.0'; - - private ydoc: Y.Doc | null = null; - private messagesMap: Y.Map | null = null; - private roomsMap: Y.Map | null = null; - private presenceMap: Y.Map | null = null; - private typingMap: Y.Map | null = null; - private wsProvider: WebsocketProvider | null = null; - private docId: string | null = null; - - // Global document sharing for same browser session - private static globalDocs = new Map(); - private static globalWsProviders = new Map(); - private static docRefCounts = new Map(); - - private messagesObserver: ((event: Y.YMapEvent) => void) | null = null; - private roomsObserver: ((event: Y.YMapEvent) => void) | null = null; - private typingObserver: ((event: Y.YMapEvent) => void) | null = null; - - constructor() { - super(); - } - - async connect(config: ConnectionConfig): Promise> { - try { - this.config = config; - if (!config.realtime?.roomId) { - return this.createErrorResult('roomId is required for Yjs connection'); - } - const docId = config.realtime.roomId; - this.docId = docId; - let ydoc = YjsPluvProvider.globalDocs.get(docId); - let wsProvider = YjsPluvProvider.globalWsProviders.get(docId); - if (!ydoc) { - ydoc = new Y.Doc(); - YjsPluvProvider.globalDocs.set(docId, ydoc); - YjsPluvProvider.docRefCounts.set(docId, 1); - const wsUrl = config.realtime.serverUrl || 'ws://localhost:3005'; - wsProvider = new WebsocketProvider(wsUrl, docId, ydoc, { - connect: true, - params: { room: docId } - }); - YjsPluvProvider.globalWsProviders.set(docId, wsProvider); - } else { - const currentCount = YjsPluvProvider.docRefCounts.get(docId) || 0; - YjsPluvProvider.docRefCounts.set(docId, currentCount + 1); - } - this.ydoc = ydoc; - this.wsProvider = wsProvider || null; - this.messagesMap = this.ydoc.getMap('messages'); - this.roomsMap = this.ydoc.getMap('rooms'); - this.presenceMap = this.ydoc.getMap('presence'); - this.typingMap = this.ydoc.getMap('typing'); - this.messagesObserver = this.handleMessagesChange.bind(this); - this.roomsObserver = this.handleRoomsChange.bind(this); - this.typingObserver = this.handleTypingChange.bind(this); - this.messagesMap.observe(this.messagesObserver); - this.roomsMap.observe(this.roomsObserver); - this.typingMap.observe(this.typingObserver); - - // Set connection state immediately to allow local operations - this.setConnectionState('connected'); - - if (this.wsProvider) { - this.wsProvider.off('status', this.handleWSStatus); - this.wsProvider.off('sync', this.handleWSSync); - this.wsProvider.on('status', this.handleWSStatus.bind(this)); - this.wsProvider.on('sync', this.handleWSSync.bind(this)); - - // Update connection state based on WebSocket status - if (this.wsProvider.wsconnected) { - this.setConnectionState('connected'); - } else if (this.wsProvider.wsconnecting) { - this.setConnectionState('connecting'); - } - } - - console.log('[YjsPluvProvider] โœ… Connected successfully with docId:', docId); - return this.createSuccessResult(undefined); - } catch (error) { - this.setConnectionState('failed'); - console.error('[YjsPluvProvider] โŒ Connection failed:', error); - return this.handleError(error, 'connect'); - } - } - - private handleWSStatus(event: any) { - if (event.status === 'connected') { - this.setConnectionState('connected'); - } else if (event.status === 'connecting') { - this.setConnectionState('connecting'); - } else if (event.status === 'disconnected') { - this.setConnectionState('connected'); // Keep local operations working - } - } - - private handleWSSync(isSynced: boolean) { - // Optionally keep for debugging sync status - } - - async disconnect(): Promise> { - try { - if (this.ydoc && this.docId) { - if (this.messagesMap && this.messagesObserver) { - this.messagesMap.unobserve(this.messagesObserver); - } - if (this.roomsMap && this.roomsObserver) { - this.roomsMap.unobserve(this.roomsObserver); - } - if (this.typingMap && this.typingObserver) { - this.typingMap.unobserve(this.typingObserver); - } - const currentCount = YjsPluvProvider.docRefCounts.get(this.docId) || 1; - if (currentCount <= 1) { - const wsProvider = YjsPluvProvider.globalWsProviders.get(this.docId); - if (wsProvider) { - wsProvider.destroy(); - YjsPluvProvider.globalWsProviders.delete(this.docId); - } - YjsPluvProvider.globalDocs.delete(this.docId); - YjsPluvProvider.docRefCounts.delete(this.docId); - } else { - YjsPluvProvider.docRefCounts.set(this.docId, currentCount - 1); - } - } - this.ydoc = null; - this.messagesMap = null; - this.roomsMap = null; - this.presenceMap = null; - this.typingMap = null; - this.wsProvider = null; - this.docId = null; - this.messagesObserver = null; - this.roomsObserver = null; - this.typingObserver = null; - this.setConnectionState('disconnected'); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'disconnect'); - } - } - - async healthCheck(): Promise> { - try { - const isHealthy = this.ydoc !== null && this.connectionState === 'connected'; - const status = { - status: isHealthy ? 'healthy' : 'disconnected', - details: { - connectionState: this.connectionState, - yjsDocConnected: this.ydoc !== null, - mapsInitialized: this.messagesMap !== null && this.roomsMap !== null, - wsConnected: this.wsProvider?.wsconnected || false, - wsConnecting: this.wsProvider?.wsconnecting || false, - docId: this.docId, - globalDocsCount: YjsPluvProvider.globalDocs.size, - globalWsProvidersCount: YjsPluvProvider.globalWsProviders.size - } - }; - return this.createSuccessResult(status); - } catch (error) { - return this.handleError(error, 'healthCheck'); - } - } - - // Room operations - async createRoom(room: Omit): Promise> { - try { - await this.ensureConnected(); - // Use room name as deterministic ID for shared rooms - const roomId = `room_${room.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`; - const newRoom: UnifiedRoom = { - id: roomId, - createdAt: Date.now(), - updatedAt: Date.now(), - ...room, - }; - this.roomsMap!.set(newRoom.id, { - id: newRoom.id, - name: newRoom.name, - type: newRoom.type, - participants: newRoom.participants, - admins: newRoom.admins, - isActive: newRoom.isActive, - createdAt: newRoom.createdAt, - updatedAt: newRoom.updatedAt, - lastActivity: newRoom.lastActivity, - }); - return this.createSuccessResult(newRoom); - } catch (error) { - return this.handleError(error, 'createRoom'); - } - } - - async getRooms(userId?: string): Promise> { - try { - await this.ensureConnected(); - const rooms: UnifiedRoom[] = []; - for (const [roomId, roomData] of this.roomsMap!.entries()) { - if (!userId || roomData.participants.includes(userId) || roomData.admins.includes(userId)) { - rooms.push({ - id: roomData.id, - name: roomData.name, - type: roomData.type, - participants: roomData.participants || [], - admins: roomData.admins || [], - creator: roomData.creator || 'unknown', - isActive: roomData.isActive ?? true, - createdAt: roomData.createdAt, - updatedAt: roomData.updatedAt, - lastActivity: roomData.lastActivity || Date.now(), - }); - } - } - return this.createSuccessResult(rooms); - } catch (error) { - return this.handleError(error, 'getRooms'); - } - } - - async getRoom(roomId: string): Promise> { - try { - await this.ensureConnected(); - const roomData = this.roomsMap!.get(roomId); - if (!roomData) { - return this.createErrorResult(`Room with id ${roomId} not found`); - } - const room: UnifiedRoom = { - id: roomData.id, - name: roomData.name, - type: roomData.type, - participants: roomData.participants || [], - admins: roomData.admins || [], - creator: roomData.creator || 'unknown', - isActive: roomData.isActive ?? true, - createdAt: roomData.createdAt, - updatedAt: roomData.updatedAt, - lastActivity: roomData.lastActivity || Date.now(), - }; - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'getRoom'); - } - } - - async getRoomByName(name: string): Promise> { - try { - await this.ensureConnected(); - for (const [roomId, roomData] of this.roomsMap!.entries()) { - if (roomData.name === name) { - const room: UnifiedRoom = { - id: roomData.id, - name: roomData.name, - type: roomData.type, - participants: roomData.participants || [], - admins: roomData.admins || [], - creator: roomData.creator || 'unknown', - isActive: roomData.isActive ?? true, - createdAt: roomData.createdAt, - updatedAt: roomData.updatedAt, - lastActivity: roomData.lastActivity || Date.now(), - }; - return this.createSuccessResult(room); - } - } - return this.createErrorResult(`Room with name ${name} not found`); - } catch (error) { - return this.handleError(error, 'getRoomByName'); - } - } - - async updateRoom(roomId: string, updates: Partial): Promise> { - try { - await this.ensureConnected(); - const roomData = this.roomsMap!.get(roomId); - if (!roomData) { - return this.createErrorResult(`Room with id ${roomId} not found`); - } - const updatedRoom = { ...roomData, ...updates, updatedAt: Date.now() }; - this.roomsMap!.set(roomId, updatedRoom); - return this.createSuccessResult(updatedRoom as UnifiedRoom); - } catch (error) { - return this.handleError(error, 'updateRoom'); - } - } - - async deleteRoom(roomId: string): Promise> { - try { - await this.ensureConnected(); - this.roomsMap!.delete(roomId); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'deleteRoom'); - } - } - - // Enhanced room management operations - async createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise> { - try { - await this.ensureConnected(); - const roomId = this.generateId(); - const now = Date.now(); - - const newRoom: UnifiedRoom = { - id: roomId, - name: request.name, - type: request.type, - participants: [creatorId], - admins: [creatorId], - creator: creatorId, - description: request.description, - maxParticipants: request.maxParticipants, - isActive: true, - lastActivity: now, - createdAt: now, - updatedAt: now, - }; - - this.roomsMap!.set(roomId, newRoom); - console.log('[YjsPluvProvider] ๐Ÿ  Created room from request:', newRoom); - return this.createSuccessResult(newRoom); - } catch (error) { - return this.handleError(error, 'createRoomFromRequest'); - } - } - - async getAvailableRooms(userId: string, filter?: RoomListFilter): Promise> { - try { - console.log('[YjsPluvProvider] ๐Ÿ” Getting available rooms for user:', userId); - console.log('[YjsPluvProvider] ๐Ÿ“Š Connection state:', this.connectionState); - console.log('[YjsPluvProvider] ๐Ÿ“„ Yjs doc available:', !!this.ydoc); - console.log('[YjsPluvProvider] ๐Ÿ—บ๏ธ Rooms map available:', !!this.roomsMap); - - await this.ensureConnected(); - const allRooms = Array.from(this.roomsMap!.values()); - - console.log('[YjsPluvProvider] ๐Ÿ“‹ Total rooms found:', allRooms.length); - - let filteredRooms = allRooms.filter(room => { - if (!room.isActive) return false; - if (filter?.type && room.type !== filter.type) return false; - if (filter?.userIsMember && !room.participants.includes(userId)) return false; - if (filter?.userCanJoin) { - const canJoin = room.type === 'public' || room.participants.includes(userId); - if (!canJoin) return false; - } - return true; - }); - - console.log('[YjsPluvProvider] โœ… Filtered rooms:', filteredRooms.length); - return this.createSuccessResult(filteredRooms); - } catch (error) { - console.error('[YjsPluvProvider] โŒ Error in getAvailableRooms:', error); - return this.handleError(error, 'getAvailableRooms'); - } - } - - async joinRoom(request: JoinRoomRequest): Promise> { - try { - await this.ensureConnected(); - const room = this.roomsMap!.get(request.roomId); - - if (!room) { - return this.createErrorResult(`Room ${request.roomId} not found`, 'ROOM_NOT_FOUND'); - } - - // Check if user can join - const canJoinResult = await this.canUserJoinRoom(request.roomId, request.userId); - if (!canJoinResult.success || !canJoinResult.data) { - return this.createErrorResult('User cannot join this room', 'ACCESS_DENIED'); - } - - // Add user to participants if not already there - if (!room.participants.includes(request.userId)) { - room.participants = [...room.participants, request.userId]; - room.updatedAt = Date.now(); - room.lastActivity = Date.now(); - this.roomsMap!.set(request.roomId, room); - - console.log(`[YjsPluvProvider] ๐Ÿšช User ${request.userName} joined room ${room.name}`); - } - - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'joinRoom'); - } - } - - async leaveRoom(roomId: string, userId: string): Promise> { - try { - await this.ensureConnected(); - const room = this.roomsMap!.get(roomId); - - if (!room) { - return this.createErrorResult(`Room ${roomId} not found`, 'ROOM_NOT_FOUND'); - } - - // Remove user from participants and admins - room.participants = room.participants.filter((id: string) => id !== userId); - room.admins = room.admins.filter((id: string) => id !== userId); - room.updatedAt = Date.now(); - room.lastActivity = Date.now(); - - this.roomsMap!.set(roomId, room); - console.log(`[YjsPluvProvider] ๐Ÿšช User ${userId} left room ${room.name}`); - - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'leaveRoom'); - } - } - - async updateRoomMembership(update: RoomMembershipUpdate): Promise> { - try { - await this.ensureConnected(); - const room = this.roomsMap!.get(update.roomId); - - if (!room) { - return this.createErrorResult(`Room ${update.roomId} not found`, 'ROOM_NOT_FOUND'); - } - - // Check if actor has permission (must be admin or creator) - if (!room.admins.includes(update.actorId) && room.creator !== update.actorId) { - return this.createErrorResult('Insufficient permissions', 'ACCESS_DENIED'); - } - - switch (update.action) { - case 'join': - if (!room.participants.includes(update.userId)) { - room.participants = [...room.participants, update.userId]; - } - break; - case 'leave': - case 'kick': - room.participants = room.participants.filter((id: string) => id !== update.userId); - room.admins = room.admins.filter((id: string) => id !== update.userId); - break; - case 'promote': - if (room.participants.includes(update.userId) && !room.admins.includes(update.userId)) { - room.admins = [...room.admins, update.userId]; - } - break; - case 'demote': - room.admins = room.admins.filter((id: string) => id !== update.userId); - break; - } - - room.updatedAt = Date.now(); - room.lastActivity = Date.now(); - this.roomsMap!.set(update.roomId, room); - - console.log(`[YjsPluvProvider] ๐Ÿ‘ฅ Membership updated - ${update.action} for user ${update.userId} in room ${room.name}`); - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'updateRoomMembership'); - } - } - - async canUserJoinRoom(roomId: string, userId: string): Promise> { - try { - await this.ensureConnected(); - const room = this.roomsMap!.get(roomId); - - if (!room || !room.isActive) { - return this.createSuccessResult(false); - } - - // Check if already a member - if (room.participants.includes(userId)) { - return this.createSuccessResult(true); - } - - // Check room type permissions - if (room.type === 'public') { - // Check max participants limit - if (room.maxParticipants && room.participants.length >= room.maxParticipants) { - return this.createSuccessResult(false); - } - return this.createSuccessResult(true); - } - - if (room.type === 'private') { - // Private rooms require invitation (already handled by admin actions) - return this.createSuccessResult(false); - } - - return this.createSuccessResult(false); - } catch (error) { - return this.handleError(error, 'canUserJoinRoom'); - } - } - - // Message operations - async sendMessage(message: Omit): Promise> { - try { - await this.ensureConnected(); - const newMessage: UnifiedMessage = { - id: this.generateId(), - timestamp: Date.now(), - status: 'synced', - ...message, - }; - const messageData = { - id: newMessage.id, - text: newMessage.text, - authorId: newMessage.authorId, - authorName: newMessage.authorName, - roomId: newMessage.roomId, - timestamp: newMessage.timestamp, - status: newMessage.status, - messageType: newMessage.messageType || 'text', - metadata: newMessage.metadata || {}, - role: newMessage.role || 'user', - }; - this.messagesMap!.set(newMessage.id, messageData); - return this.createSuccessResult(newMessage); - } catch (error) { - return this.handleError(error, 'sendMessage'); - } - } - - async getMessages(roomId: string, limit?: number, before?: number): Promise> { - try { - await this.ensureConnected(); - const messages: UnifiedMessage[] = []; - for (const [messageId, messageData] of this.messagesMap!.entries()) { - if (messageData.roomId === roomId) { - if (before && messageData.timestamp >= before) { - continue; - } - const message: UnifiedMessage = { - id: messageData.id, - text: messageData.text, - authorId: messageData.authorId, - authorName: messageData.authorName, - roomId: messageData.roomId, - timestamp: messageData.timestamp, - status: messageData.status || 'synced', - messageType: messageData.messageType || 'text', - metadata: messageData.metadata || {}, - role: messageData.role || 'user', - }; - messages.push(message); - } - } - messages.sort((a, b) => a.timestamp - b.timestamp); - const limitedMessages = limit ? messages.slice(-limit) : messages; - return this.createSuccessResult(limitedMessages); - } catch (error) { - return this.handleError(error, 'getMessages'); - } - } - - async getMessage(messageId: string): Promise> { - try { - await this.ensureConnected(); - const messageData = this.messagesMap!.get(messageId); - if (!messageData) { - return this.createErrorResult(`Message with id ${messageId} not found`); - } - const message: UnifiedMessage = { - id: messageData.id, - text: messageData.text, - authorId: messageData.authorId, - authorName: messageData.authorName, - roomId: messageData.roomId, - timestamp: messageData.timestamp, - status: messageData.status || 'synced', - messageType: messageData.messageType || 'text', - metadata: messageData.metadata || {}, - role: messageData.role || 'user', - }; - return this.createSuccessResult(message); - } catch (error) { - return this.handleError(error, 'getMessage'); - } - } - - async updateMessage(messageId: string, updates: Partial): Promise> { - try { - await this.ensureConnected(); - const messageData = this.messagesMap!.get(messageId); - if (!messageData) { - return this.createErrorResult(`Message with id ${messageId} not found`); - } - const updatedMessage = { ...messageData, ...updates }; - this.messagesMap!.set(messageId, updatedMessage); - return this.createSuccessResult(updatedMessage as UnifiedMessage); - } catch (error) { - return this.handleError(error, 'updateMessage'); - } - } - - async deleteMessage(messageId: string): Promise> { - try { - await this.ensureConnected(); - this.messagesMap!.delete(messageId); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'deleteMessage'); - } - } - - // Presence operations - async updatePresence(presence: Partial): Promise> { - try { - await this.ensureConnected(); - if (!presence.userId) { - return this.createErrorResult('userId is required for presence update'); - } - const currentPresence = this.presenceMap!.get(presence.userId) || {}; - const updatedPresence = { ...currentPresence, ...presence }; - this.presenceMap!.set(presence.userId, updatedPresence); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'updatePresence'); - } - } - - async getPresence(roomId: string): Promise> { - try { - await this.ensureConnected(); - const presenceList: UserPresence[] = []; - for (const [userId, presence] of this.presenceMap!.entries()) { - if (presence.currentRoom === roomId) { - presenceList.push(presence); - } - } - return this.createSuccessResult(presenceList); - } catch (error) { - return this.handleError(error, 'getPresence'); - } - } - - // Typing operations - async startTyping(roomId: string): Promise> { - try { - await this.ensureConnected(); - - if (!this.config?.userId || !this.config?.userName) { - return this.handleError(new Error('User ID and name required for typing indicators'), 'startTyping'); - } - - const typingKey = `${roomId}_${this.config.userId}`; - const typingData: TypingState = { - userId: this.config.userId, - userName: this.config.userName, - roomId: roomId, - startTime: Date.now() - }; - - console.log('[YjsPluvProvider] ๐Ÿ–Š๏ธ STARTING TYPING:', typingData); - this.typingMap!.set(typingKey, { ...typingData, isTyping: true }); - - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'startTyping'); - } - } - - async stopTyping(roomId: string): Promise> { - try { - await this.ensureConnected(); - - if (!this.config?.userId) { - return this.handleError(new Error('User ID required for typing indicators'), 'stopTyping'); - } - - const typingKey = `${roomId}_${this.config.userId}`; - console.log('[YjsPluvProvider] ๐Ÿ–Š๏ธ STOPPING TYPING:', this.config.userId); - this.typingMap!.delete(typingKey); - - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'stopTyping'); - } - } - - // Utility operations - async clearRoomData(roomId: string): Promise> { - try { - await this.ensureConnected(); - const messagesToDelete = []; - for (const [messageId, messageData] of this.messagesMap!.entries()) { - if (messageData.roomId === roomId) { - messagesToDelete.push(messageId); - } - } - messagesToDelete.forEach(messageId => { - this.messagesMap!.delete(messageId); - }); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'clearRoomData'); - } - } - - async exportData(): Promise> { - try { - await this.ensureConnected(); - const exportData: { - version: string; - provider: string; - timestamp: number; - rooms: { [key: string]: any }; - messages: { [key: string]: any }; - presence: { [key: string]: any }; - } = { - version: this.version, - provider: this.name, - timestamp: Date.now(), - rooms: {}, - messages: {}, - presence: {} - }; - for (const [roomId, roomData] of this.roomsMap!.entries()) { - exportData.rooms[roomId] = roomData; - } - for (const [messageId, messageData] of this.messagesMap!.entries()) { - exportData.messages[messageId] = messageData; - } - for (const [userId, presenceData] of this.presenceMap!.entries()) { - exportData.presence[userId] = presenceData; - } - return this.createSuccessResult(exportData); - } catch (error) { - return this.handleError(error, 'exportData'); - } - } - - async importData(data: any): Promise> { - try { - await this.ensureConnected(); - if (!data || data.provider !== this.name) { - return this.createErrorResult('Invalid import data format'); - } - if (data.rooms) { - Object.entries(data.rooms).forEach(([roomId, roomData]: [string, any]) => { - this.roomsMap!.set(roomId, roomData); - }); - } - if (data.messages) { - Object.entries(data.messages).forEach(([messageId, messageData]: [string, any]) => { - this.messagesMap!.set(messageId, messageData); - }); - } - if (data.presence) { - Object.entries(data.presence).forEach(([userId, presenceData]: [string, any]) => { - this.presenceMap!.set(userId, presenceData); - }); - } - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'importData'); - } - } - - // Event handlers for Yjs changes - private handleMessagesChange(event: Y.YMapEvent) { - event.changes.keys.forEach((change, key) => { - if (change.action === 'add') { - const messageData = this.messagesMap!.get(key); - if (messageData) { - this.notifyRoomSubscribers(messageData.roomId, { - type: 'message_added', - roomId: messageData.roomId, - userId: messageData.authorId, - data: { - id: messageData.id, - text: messageData.text, - authorId: messageData.authorId, - authorName: messageData.authorName, - roomId: messageData.roomId, - timestamp: messageData.timestamp, - status: messageData.status || 'synced', - messageType: messageData.messageType || 'text', - metadata: messageData.metadata || {}, - role: messageData.role || 'user', - }, - timestamp: Date.now(), - }); - } - } else if (change.action === 'update') { - const messageData = this.messagesMap!.get(key); - if (messageData) { - this.notifyRoomSubscribers(messageData.roomId, { - type: 'message_updated', - roomId: messageData.roomId, - userId: messageData.authorId, - data: { - id: messageData.id, - text: messageData.text, - authorId: messageData.authorId, - authorName: messageData.authorName, - roomId: messageData.roomId, - timestamp: messageData.timestamp, - status: messageData.status || 'synced', - messageType: messageData.messageType || 'text', - metadata: messageData.metadata || {}, - role: messageData.role || 'user', - }, - timestamp: Date.now(), - }); - } - } else if (change.action === 'delete') { - this.roomSubscriptions.forEach((callbacks, roomId) => { - this.notifyRoomSubscribers(roomId, { - type: 'message_deleted', - roomId: roomId, - data: { messageId: key }, - timestamp: Date.now(), - }); - }); - } - }); - } - - private handleRoomsChange(event: Y.YMapEvent) { - event.changes.keys.forEach((change, key) => { - if (change.action === 'add') { - const roomData = this.roomsMap!.get(key); - if (roomData) { - this.notifyRoomSubscribers(key, { - type: 'room_updated', - roomId: key, - data: roomData, - timestamp: Date.now(), - }); - } - } - }); - } - - private handleTypingChange(event: Y.YMapEvent) { - console.log('[YjsPluvProvider] ๐Ÿ–Š๏ธ TYPING MAP CHANGED!'); - event.changes.keys.forEach((change, key) => { - console.log(`[YjsPluvProvider] ๐Ÿ–Š๏ธ Typing change - Action: ${change.action}, Key: ${key}`); - if (change.action === 'add' || change.action === 'update') { - const typingData = this.typingMap!.get(key); - if (typingData) { - console.log('[YjsPluvProvider] ๐Ÿ–Š๏ธ TYPING STATE:', typingData); - const eventType = typingData.isTyping ? 'typing_started' : 'typing_stopped'; - this.notifyRoomSubscribers(typingData.roomId, { - type: eventType, - roomId: typingData.roomId, - userId: typingData.userId, - data: typingData, - timestamp: Date.now(), - }); - } - } else if (change.action === 'delete') { - console.log(`[YjsPluvProvider] ๐Ÿ–Š๏ธ Typing entry deleted for key: ${key}`); - // When typing indicator expires, notify subscribers - const parts = key.split('_'); - if (parts.length >= 2) { - const roomId = parts[0]; - const userId = parts[1]; - console.log(`[YjsPluvProvider] ๐Ÿ–Š๏ธ Notifying typing_stopped for user: ${userId} in room: ${roomId}`); - this.notifyRoomSubscribers(roomId, { - type: 'typing_stopped', - roomId: roomId, - userId: userId, - data: { userId, roomId, isTyping: false }, - timestamp: Date.now(), - }); - } - } - }); - } - - private async ensureConnected(): Promise { - if (!this.ydoc) { - throw new Error('YjsPluvProvider is not connected - no Yjs document available'); - } - - // Allow operations even if WebSocket is still connecting, as Yjs works locally - if (this.connectionState === 'failed' || this.connectionState === 'disconnected') { - throw new Error('YjsPluvProvider is not connected - connection state: ' + this.connectionState); - } - } -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/types/chatDataTypes.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/types/chatDataTypes.ts deleted file mode 100644 index 64aebe4757..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/types/chatDataTypes.ts +++ /dev/null @@ -1,408 +0,0 @@ -// Core data types for unified chat system -// Compatible with existing ALASql structure while extensible for real-time collaboration - -export type MessageStatus = 'sending' | 'sent' | 'failed' | 'synced'; -export type MessageType = 'text' | 'file' | 'system' | 'action'; -export type RoomType = 'private' | 'public' | 'group'; -export type UserStatus = 'online' | 'away' | 'busy' | 'offline'; - -// Unified message format - backward compatible with existing MyMessage -export interface UnifiedMessage { - // Core fields (existing ALASql compatibility) - id: string; - text: string; - timestamp: number; - - // Author information for multi-user support - authorId: string; - authorName: string; - - // Room/thread association - roomId: string; - - // Message status and type - status: MessageStatus; - messageType: MessageType; - - // Real-time collaboration metadata (optional for future use) - yjsId?: string; // Yjs document reference - version?: number; // Version for conflict resolution - localId?: string; // Local optimistic ID - - // Extensibility - metadata?: Record; - - // Legacy compatibility (for existing ChatComp) - role?: "user" | "assistant"; // Maps to authorId types -} - -// Unified room format - compatible with existing thread structure -export interface UnifiedRoom { - // Core identification - id: string; - name: string; - type: RoomType; - - // Participants management - participants: string[]; // User IDs - admins: string[]; // Admin user IDs - creator: string; // User ID who created the room - - // Room settings - description?: string; // Optional room description - maxParticipants?: number; // Optional participant limit - - // State and metadata - isActive: boolean; - lastActivity: number; - createdAt: number; - updatedAt: number; - - // Real-time collaboration (optional) - yjsDocId?: string; // Yjs document ID for this room - - // Legacy compatibility - status?: "regular" | "archived"; // For existing thread system - title?: string; // Alias for name - threadId?: string; // Alias for id -} - -// User presence for real-time features -export interface UserPresence { - userId: string; - userName: string; - avatar?: string; - status: UserStatus; - lastSeen: number; - currentRoom?: string; - typingIn?: string; // Room ID where user is typing -} - -// Typing indicator state -export interface TypingState { - userId: string; - userName: string; - roomId: string; - startTime: number; -} - -// Room management interfaces -export interface CreateRoomRequest { - name: string; - type: RoomType; - description?: string; - maxParticipants?: number; - isPrivate?: boolean; -} - -export interface JoinRoomRequest { - roomId: string; - userId: string; - userName: string; -} - -export interface RoomMembershipUpdate { - roomId: string; - userId: string; - action: 'join' | 'leave' | 'promote' | 'demote' | 'kick'; - actorId: string; // Who performed the action -} - -export interface RoomListFilter { - type?: RoomType; - isActive?: boolean; - userCanJoin?: boolean; - userIsMember?: boolean; -} - -// Connection state for real-time providers -export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'failed'; - -// Chat events for real-time subscriptions -export type ChatEventType = 'message_added' | 'message_updated' | 'message_deleted' | - 'room_updated' | 'user_joined' | 'user_left' | - 'typing_started' | 'typing_stopped' | 'presence_updated'; - -export interface ChatEvent { - type: ChatEventType; - roomId: string; - userId?: string; - data: any; - timestamp: number; -} - -// Configuration types -export interface ConnectionConfig { - mode: 'local' | 'collaborative' | 'hybrid'; - userId: string; - userName: string; - - // Local storage config - alasql?: { - dbName: string; - tableName?: string; - }; - - // Real-time collaboration config (for future use) - realtime?: { - serverUrl: string; - roomId: string; - authToken?: string; - }; -} - -// Provider operation results -export interface OperationResult { - success: boolean; - data?: T; - error?: string; - timestamp: number; -} - -// DataTransformUtils - Handles conversion between different data formats -export class DataTransformUtils { - // ALASql transformations (existing) - static toALASqlMessage(message: UnifiedMessage): any { - return { - id: message.id, - role: 'user', // Map authorId to role for backward compatibility - text: message.text, - timestamp: message.timestamp, - threadId: message.roomId, // Map roomId to threadId for ALASql compatibility - authorId: message.authorId, - authorName: message.authorName, - status: message.status || 'sent', - metadata: JSON.stringify(message.metadata || {}) - }; - } - - static fromALASqlMessage(data: any): UnifiedMessage { - return { - id: data.id, - text: data.text, - authorId: data.authorId || data.userId || 'unknown', - authorName: data.authorName || data.userName || 'Unknown User', - roomId: data.threadId || data.roomId, - timestamp: data.timestamp, - status: data.status || 'sent', - messageType: 'text', - metadata: data.metadata ? JSON.parse(data.metadata) : {} - }; - } - - static toALASqlRoom(room: UnifiedRoom): any { - return { - id: room.id, - name: room.name, - type: room.type, - participants: JSON.stringify(room.participants), - admins: JSON.stringify(room.admins || []), - isActive: room.isActive, - createdAt: room.createdAt, - updatedAt: room.updatedAt, - lastActivity: room.lastActivity - }; - } - - static fromALASqlRoom(data: any): UnifiedRoom { - return { - id: data.id, - name: data.name, - type: data.type || 'private', - participants: data.participants ? JSON.parse(data.participants) : [], - admins: data.admins ? JSON.parse(data.admins) : [], - creator: data.creator || 'unknown', - isActive: data.isActive !== false, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - lastActivity: data.lastActivity || data.updatedAt - }; - } - - // Yjs transformations (new) - static toYjsMessage(message: UnifiedMessage): any { - return { - id: message.id, - text: message.text, - authorId: message.authorId, - authorName: message.authorName, - roomId: message.roomId, - timestamp: message.timestamp, - status: message.status || 'sent', - messageType: message.messageType, - metadata: message.metadata || {} - }; - } - - static fromYjsMessage(data: any): UnifiedMessage { - return { - id: data.id, - text: data.text, - authorId: data.authorId, - authorName: data.authorName, - roomId: data.roomId, - timestamp: data.timestamp, - status: data.status || 'sent', - messageType: data.messageType || 'text', - metadata: data.metadata || {} - }; - } - - static toYjsRoom(room: UnifiedRoom): any { - return { - id: room.id, - name: room.name, - type: room.type, - participants: room.participants, - admins: room.admins || [], - isActive: room.isActive, - createdAt: room.createdAt, - updatedAt: room.updatedAt, - lastActivity: room.lastActivity - }; - } - - static fromYjsRoom(data: any): UnifiedRoom { - return { - id: data.id, - name: data.name, - type: data.type || 'private', - participants: data.participants || [], - admins: data.admins || [], - creator: data.creator || 'unknown', - isActive: data.isActive !== false, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - lastActivity: data.lastActivity || data.updatedAt - }; - } - - static toYjsPresence(presence: UserPresence): any { - return { - userId: presence.userId, - userName: presence.userName, - status: presence.status || 'online', - lastSeen: presence.lastSeen || Date.now(), - currentRoom: presence.currentRoom, - typingIn: presence.typingIn - }; - } - - static fromYjsPresence(data: any): UserPresence { - return { - userId: data.userId, - userName: data.userName, - status: data.status || 'online', - lastSeen: data.lastSeen || Date.now(), - currentRoom: data.currentRoom, - typingIn: data.typingIn - }; - } - - // Pluv.io transformations (new) - static toPluvPresence(presence: UserPresence): any { - return { - userId: presence.userId, - userName: presence.userName, - status: presence.status || 'online', - typing: !!presence.typingIn, - lastSeen: presence.lastSeen || Date.now() - }; - } - - static fromPluvPresence(data: any): UserPresence { - return { - userId: data.userId, - userName: data.userName, - status: data.status || 'online', - lastSeen: data.lastSeen || Date.now(), - currentRoom: data.roomId - }; - } - - // Legacy ALASql thread conversions (for backward compatibility) - static fromLegacyThread(thread: any): UnifiedRoom { - return { - id: thread.threadId, - name: thread.title, - type: 'private', - participants: [], - admins: [], - creator: 'legacy_user', - isActive: thread.status !== 'archived', - lastActivity: thread.updatedAt, - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - }; - } - - static toLegacyThread(room: UnifiedRoom): any { - return { - threadId: room.id, - status: room.isActive ? 'regular' : 'archived', - title: room.name, - createdAt: room.createdAt, - updatedAt: room.updatedAt, - }; - } - - static fromLegacyMessage(msg: any, roomId: string, authorId: string, authorName: string): UnifiedMessage { - return { - id: msg.id, - text: msg.text, - authorId, - authorName, - roomId, - timestamp: msg.timestamp, - status: 'synced', - messageType: 'text', - metadata: {}, - role: msg.role === 'assistant' ? 'assistant' : 'user' - }; - } - - static toLegacyMessage(message: UnifiedMessage): any { - return { - id: message.id, - role: message.role || 'user', - text: message.text, - timestamp: message.timestamp, - threadId: message.roomId, - }; - } - - // Validation helpers - static validateMessage(data: any): boolean { - return !!(data.id && data.text && data.authorId && data.roomId && data.timestamp); - } - - static validateRoom(data: any): boolean { - return !!(data.id && data.name && data.type); - } - - static validatePresence(data: any): boolean { - return !!(data.userId && data.userName); - } -} - -// Error types for better error handling -export class ChatDataError extends Error { - constructor( - message: string, - public code: string, - public details?: any - ) { - super(message); - this.name = 'ChatDataError'; - } -} - -export enum ChatErrorCodes { - CONNECTION_FAILED = 'CONNECTION_FAILED', - OPERATION_TIMEOUT = 'OPERATION_TIMEOUT', - PERMISSION_DENIED = 'PERMISSION_DENIED', - ROOM_NOT_FOUND = 'ROOM_NOT_FOUND', - MESSAGE_NOT_FOUND = 'MESSAGE_NOT_FOUND', - VALIDATION_ERROR = 'VALIDATION_ERROR', - STORAGE_ERROR = 'STORAGE_ERROR', -} \ No newline at end of file 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 0000000000..77bc3f82f4 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx @@ -0,0 +1,86 @@ +import { createContext, useContext } from "react"; +import type { ChatRoom, OnlineUser, PendingRoomInvite } from "./store"; +import type { + ChatBoxV2ContainerStyleType, + ChatBoxV2SidebarStyleType, + ChatBoxV2HeaderStyleType, + ChatBoxV2MessageStyleType, + ChatBoxV2InputStyleType, + AnimationStyleType, +} from "comps/controls/styleControlConstants"; + +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[]; + onlineUsers: OnlineUser[]; + pendingInvites: PendingRoomInvite[]; + isAiThinking: boolean; + + // Exposed state + chatTitle: ExposedState; + messageText: ExposedState; + lastSentMessageText: ExposedState; + + // UI config + showHeader: boolean; + showRoomsPanel: boolean; + roomsPanelWidth: string; + allowRoomCreation: boolean; + allowRoomSearch: boolean; + style: ChatBoxV2ContainerStyleType; + animationStyle: AnimationStyleType; + sidebarStyle: ChatBoxV2SidebarStyleType; + headerStyle: ChatBoxV2HeaderStyleType; + messageStyle: ChatBoxV2MessageStyleType; + inputStyle: ChatBoxV2InputStyleType; + + // 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/README.md b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md new file mode 100644 index 0000000000..641fdd7e1e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md @@ -0,0 +1,789 @@ +# Chat V2 โ€” Complete Reference & Testing Guide + +## Architecture + +The Chat V2 system is split into two Lowcoder components with a clear separation of concerns: + +| Layer | Component | Responsibility | +|-------|-----------|---------------| +| **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) | + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 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 +} +``` + +--- + +## Prerequisites + +### 1. Pluv.io Account + +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 + +### 2. Pluv Auth Server + +The auth server mints short-lived tokens for Pluv connections. + +```bash +cd client/packages/lowcoder + +# Provide your Pluv keys +export PLUV_PUBLISHABLE_KEY="pk_..." +export PLUV_SECRET_KEY="sk_..." + +# Start (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", ... } + +curl "http://localhost:3006/api/auth/pluv?room=signal_myapp&userId=user_1&userName=Alice" +# โ†’ { "token": "..." } +``` + +--- + +## 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: + +| 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 | + +The component is typically named `chatController1` automatically. + +--- + +### Step 2 โ€” Add `Chat Box V2` + +1. In Insert panel, search **"Chat Box V2"** โ†’ drag onto canvas +2. Configure: + +| 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 | + +--- + +### Step 3 โ€” Create Data Queries + +You need at minimum: **loadMessages** and **saveMessage**. + +#### `loadMessages` โ€” MongoDB example + +```js +// Collection: chat_messages +// Operation: Find +// Filter: +{ "roomId": "{{ chatController1.currentRoomId || 'general' }}" } +// Sort: +{ "timestamp": 1 } +``` + +#### `loadMessages` โ€” REST API example + +``` +GET https://your-api.com/messages?roomId={{ chatController1.currentRoomId || 'general' }} +``` + +#### `saveMessage` โ€” MongoDB example + +```js +// Collection: chat_messages +// Operation: Insert +{ + "id": "{{ uid() }}", + "roomId": "{{ chatController1.currentRoomId || 'general' }}", + "text": "{{ chatBox1.lastSentMessageText }}", + "authorId": "{{ chatController1.userId }}", + "authorName": "{{ chatController1.userName }}", + "timestamp": "{{ Date.now() }}" +} +``` + +--- + +### Step 4 โ€” Wire Up Events + +#### On `Chat Box V2` (chatBox1): + +| 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)` | โ€” | + +#### On `Chat Signal Controller` (chatController1): + +| 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 | + +--- + +### Step 5 โ€” Test + +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 + +--- + +## Rooms Deep Dive + +### How Native Rooms Work + +Rooms are stored in a **Yjs YMap** (`rooms`) inside the Pluv signal room. This means: + +- โœ… 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 + +### Room Panel UI + +The built-in sidebar groups rooms by type: + +``` +Rooms [+] +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +AI ROOMS + ๐Ÿค– GPT Assistant AI +PUBLIC + ๐ŸŒ General + ๐ŸŒ Announcements +PRIVATE + ๐Ÿ”’ Design Team + ๐Ÿ”’ Backend Squad +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +[Search public rooms...] +``` + +- 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 + +### Public Rooms + +Visible to everyone in the signal room. No membership tracking. Anyone can join via search. + +``` +createRoom("General Chat", "public", "For everyone") +``` + +### Private Rooms + +Members-only. The creator is auto-added to the members list. Others join by invite. + +``` +createRoom("Backend Team", "private", "Internal discussions") +// Then invite someone: +sendInvite(roomId, "user_99", "Bob") +``` + +### 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") +``` + +The `llmQueryName` field stores the **exact name of a Lowcoder query** you've created. Your query receives: + +```json +{ + "prompt": "the user's message text", + "roomId": "the room id", + "conversationHistory": [ ...recent messages array... ] +} +``` + +> **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 + +Wire the `roomCreated` event on the controller to your save query: + +```js +// 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() }}" +} +``` + +--- + +## Controller Reference (`chatController1`) + +### Properties (read via `{{ chatController1.propertyName }}`) + +| 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 | + +--- + +### 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()` | + +--- + +### 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 + +| Method | Parameters | Description | +|--------|-----------|-------------| +| `setUser(userId, userName)` | `userId: string`, `userName: string` | Update user identity at runtime. | + +#### Room Management + +| 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. | + +--- + +## Chat Box Reference (`chatBox1`) + +### Properties (read via `{{ chatBox1.propertyName }}`) + +| 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 + +| 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 + +``` +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() +``` + +--- + +## LLM / AI Room Setup + +1. Create a Lowcoder query (e.g. `getAIResponse`) that calls your AI backend +2. The query receives these input arguments: + + ```json + { + "prompt": "What is the capital of France?", + "roomId": "room_abc123", + "conversationHistory": [ + { "authorType": "user", "text": "...", "authorId": "user_1" }, + { "authorType": "assistant", "text": "...", "authorId": "__llm_bot__" } + ] + } + ``` + +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: + + ```js + // Conditional action: + if (chatController1.rooms.find(r => r.id === chatController1.currentRoomId)?.type === 'llm') { + getAIResponse.run(); + } + ``` + +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 + +--- + +## Local Development & Testing + +### 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. + +### 4. Minimal Smoke Test (No Database) + +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 + +### 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 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 | 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/READMEv2.md b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/READMEv2.md new file mode 100644 index 0000000000..871b453683 --- /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 0000000000..05fc760ff9 --- /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/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx new file mode 100644 index 0000000000..347897196b --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -0,0 +1,367 @@ +import React, { useContext } from "react"; +import { Section, sectionNames } from "lowcoder-design"; +import { UICompBuilder, withDefault } from "../../generators"; +import { + NameConfig, + NameConfigHidden, + withExposingConfigs, +} from "../../generators/withExposing"; +import { stringExposingStateControl } from "comps/controls/codeStateControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { StringControl, jsonArrayControl } from "comps/controls/codeControl"; +import { AutoHeightControl } from "comps/controls/autoHeightControl"; +import { eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { styleControl } from "comps/controls/styleControl"; +import { + AnimationStyle, + ChatBoxV2ContainerStyle, + ChatBoxV2SidebarStyle, + ChatBoxV2HeaderStyle, + ChatBoxV2MessageStyle, + ChatBoxV2InputStyle, +} from "comps/controls/styleControlConstants"; +import { hiddenPropertyView } from "comps/utils/propertyUtils"; +import { EditorContext } from "comps/editorState"; +import { trans } from "i18n"; + +import { ChatBoxView } from "./components/ChatBoxView"; +import { ChatBoxContext } from "./ChatBoxContext"; +import type { ChatRoom, PendingRoomInvite } from "./store"; + +// โ”€โ”€โ”€ Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const ChatEvents = [ + { + label: trans("chatBoxV2.messageSent"), + value: "messageSent", + description: trans("chatBoxV2.messageSentDesc"), + }, + { + label: trans("chatBoxV2.startTyping"), + value: "startTyping", + description: trans("chatBoxV2.startTypingDesc"), + }, + { + label: trans("chatBoxV2.stopTyping"), + value: "stopTyping", + description: trans("chatBoxV2.stopTypingDesc"), + }, + { + label: trans("chatBoxV2.roomSwitch"), + value: "roomSwitch", + description: trans("chatBoxV2.roomSwitchDesc"), + }, + { + label: trans("chatBoxV2.roomJoin"), + value: "roomJoin", + description: trans("chatBoxV2.roomJoinDesc"), + }, + { + label: trans("chatBoxV2.roomLeave"), + value: "roomLeave", + description: trans("chatBoxV2.roomLeaveDesc"), + }, + { + label: trans("chatBoxV2.roomCreate"), + value: "roomCreate", + description: trans("chatBoxV2.roomCreateDesc"), + }, + { + label: trans("chatBoxV2.inviteSend"), + value: "inviteSend", + description: trans("chatBoxV2.inviteSendDesc"), + }, + { + label: trans("chatBoxV2.inviteAccept"), + value: "inviteAccept", + description: trans("chatBoxV2.inviteAcceptDesc"), + }, + { + label: trans("chatBoxV2.inviteDecline"), + value: "inviteDecline", + description: trans("chatBoxV2.inviteDeclineDesc"), + }, +] as const; + +// โ”€โ”€โ”€ Children map โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const childrenMap = { + // โ”€โ”€ Chat content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + chatTitle: stringExposingStateControl("chatTitle", trans("chatBoxV2.chatTitleDefault")), + showHeader: withDefault(BoolControl, true), + messages: jsonArrayControl([]), + currentUserId: withDefault(StringControl, "user_1"), + currentUserName: withDefault(StringControl, trans("chatBoxV2.currentUserNameDefault")), + typingUsers: jsonArrayControl([]), + isAiThinking: withDefault(BoolControl, false), + lastSentMessageText: stringExposingStateControl("lastSentMessageText", ""), + messageText: stringExposingStateControl("messageText", ""), + + // โ”€โ”€ Rooms panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + rooms: jsonArrayControl([]), + currentRoomId: withDefault(StringControl, ""), + pendingInvites: jsonArrayControl([]), + onlineUsers: 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(ChatBoxV2ContainerStyle, "style"), + animationStyle: styleControl(AnimationStyle, "animationStyle"), + sidebarStyle: styleControl(ChatBoxV2SidebarStyle, "sidebarStyle"), + headerStyle: styleControl(ChatBoxV2HeaderStyle, "headerStyle"), + messageStyle: styleControl(ChatBoxV2MessageStyle, "messageStyle"), + inputStyle: styleControl(ChatBoxV2InputStyle, "inputStyle"), +}; + +// โ”€โ”€โ”€ Property panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const ChatBoxPropertyView = React.memo((props: { children: any }) => { + const { children } = props; + const editorMode = useContext(EditorContext).editorModeStatus; + + return ( + <> +
+ {children.chatTitle.propertyView({ + label: trans("chatBoxV2.chatTitleLabel"), + tooltip: trans("chatBoxV2.chatTitleTooltip"), + })} + {children.messages.propertyView({ + label: trans("chatBoxV2.messagesLabel"), + tooltip: trans("chatBoxV2.messagesTooltip"), + })} + {children.currentUserId.propertyView({ + label: trans("chatBoxV2.currentUserIdLabel"), + tooltip: trans("chatBoxV2.currentUserIdTooltip"), + })} + {children.currentUserName.propertyView({ + label: trans("chatBoxV2.currentUserNameLabel"), + tooltip: trans("chatBoxV2.currentUserNameTooltip"), + })} +
+ +
+ {children.showRoomsPanel.propertyView({ label: trans("chatBoxV2.showRoomsPanelLabel") })} + {children.roomsPanelWidth.propertyView({ + label: trans("chatBoxV2.panelWidthLabel"), + tooltip: trans("chatBoxV2.panelWidthTooltip"), + })} + {children.rooms.propertyView({ + label: trans("chatBoxV2.roomsLabel"), + tooltip: trans("chatBoxV2.roomsTooltip"), + })} + {children.currentRoomId.propertyView({ + label: trans("chatBoxV2.currentRoomIdLabel"), + tooltip: trans("chatBoxV2.currentRoomIdTooltip"), + })} + {children.pendingInvites.propertyView({ + label: trans("chatBoxV2.pendingInvitesLabel"), + tooltip: trans("chatBoxV2.pendingInvitesTooltip"), + })} + {children.allowRoomCreation.propertyView({ label: trans("chatBoxV2.allowRoomCreationLabel") })} + {children.allowRoomSearch.propertyView({ label: trans("chatBoxV2.allowRoomSearchLabel") })} +
+ +
+ {children.typingUsers.propertyView({ + label: trans("chatBoxV2.typingUsersLabel"), + tooltip: trans("chatBoxV2.typingUsersTooltip"), + })} + {children.isAiThinking.propertyView({ + label: trans("chatBoxV2.aiIsThinkingLabel"), + tooltip: trans("chatBoxV2.aiIsThinkingTooltip"), + })} + {children.onlineUsers.propertyView({ + label: trans("chatBoxV2.onlineUsersLabel"), + tooltip: trans("chatBoxV2.onlineUsersTooltip"), + })} +
+ +
+ {children.showHeader.propertyView({ label: trans("chatBoxV2.showHeaderLabel") })} +
+ + {["logic", "both"].includes(editorMode) && ( +
+ {hiddenPropertyView(children)} + {children.onEvent.getPropertyView()} +
+ )} + + {["layout", "both"].includes(editorMode) && ( + <> +
+ {children.autoHeight.getPropertyView()} +
+
+ {children.style.getPropertyView()} +
+
+ {children.sidebarStyle.getPropertyView()} +
+
+ {children.headerStyle.getPropertyView()} +
+
+ {children.messageStyle.getPropertyView()} +
+
+ {children.inputStyle.getPropertyView()} +
+
+ {children.animationStyle.getPropertyView()} +
+ + )} + + ); +}); + +ChatBoxPropertyView.displayName = "ChatBoxV2PropertyView"; + +// โ”€โ”€โ”€ Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +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 onlineUsers = Array.isArray(props.onlineUsers) ? props.onlineUsers : []; + const isAiThinking = Boolean(props.isAiThinking); + 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, + onlineUsers: onlineUsers as any, + isAiThinking, + 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, + sidebarStyle: props.sidebarStyle, + headerStyle: props.headerStyle, + messageStyle: props.messageStyle, + inputStyle: props.inputStyle, + + 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 ( + + + + ); + }) + .setPropertyViewFn((children) => ( + + )) + .build(); +})(); + +ChatBoxV2Tmp = class extends ChatBoxV2Tmp { + override autoHeight(): boolean { + return this.children.autoHeight.getView(); + } +}; + +export const ChatBoxV2Comp = withExposingConfigs(ChatBoxV2Tmp, [ + new NameConfig("chatTitle", trans("chatBoxV2.chatTitleExposed")), + new NameConfig( + "lastSentMessageText", + trans("chatBoxV2.lastSentMessageTextExposed"), + ), + new NameConfig("messageText", trans("chatBoxV2.messageTextExposed")), + new NameConfig("currentRoomId", trans("chatBoxV2.currentRoomIdExposed")), + new NameConfig( + "pendingRoomId", + trans("chatBoxV2.pendingRoomIdExposed"), + ), + new NameConfig("newRoomName", trans("chatBoxV2.newRoomNameExposed")), + new NameConfig( + "newRoomType", + trans("chatBoxV2.newRoomTypeExposed"), + ), + new NameConfig("newRoomDescription", trans("chatBoxV2.newRoomDescriptionExposed")), + new NameConfig( + "newRoomLlmQuery", + trans("chatBoxV2.newRoomLlmQueryExposed"), + ), + new NameConfig( + "inviteTargetUserId", + trans("chatBoxV2.inviteTargetUserIdExposed"), + ), + new NameConfig( + "pendingInviteId", + trans("chatBoxV2.pendingInviteIdExposed"), + ), + 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 new file mode 100644 index 0000000000..5490e7de24 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -0,0 +1,125 @@ +import React, { useMemo, useState } from "react"; +import { + Wrapper, + ChatPanelContainer, + ChatHeaderBar, + OnlineCountBadge, + OnlineCountDot, +} from "../styles"; +import { MessageList } from "./MessageList"; +import { InputBar } from "./InputBar"; +import { RoomPanel } from "./RoomPanel"; +import { CreateRoomModal } from "./CreateRoomModal"; +import { InviteUserModal } from "./InviteUserModal"; +import { useChatBox } from "../ChatBoxContext"; +import type { ChatRoom } from "../store"; +import { trans } from "i18n"; + +export const ChatBoxView = React.memo(() => { + const ctx = useChatBox(); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [inviteModalOpen, setInviteModalOpen] = useState(false); + + const headerTitle = ctx.currentRoom + ? ctx.currentRoom.name + : ctx.chatTitle.value; + + const roomOnlineCount = useMemo(() => { + if (!ctx.currentRoomId) return 0; + return ctx.onlineUsers.filter( + (u) => u.currentRoomId === ctx.currentRoomId && u.userId !== ctx.currentUserId, + ).length + 1; + }, [ctx.onlineUsers, ctx.currentRoomId, ctx.currentUserId]); + + return ( + + {/* โ”€โ”€ Rooms sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {ctx.showRoomsPanel && ( + setCreateModalOpen(true)} + onInviteModalOpen={ + ctx.currentRoom?.type === "private" + ? () => setInviteModalOpen(true) + : undefined + } + /> + )} + + {/* โ”€โ”€ Chat area โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + + {ctx.showHeader && ( + +
+
+ {headerTitle} +
+ {ctx.currentRoom?.description && ( +
+ {ctx.currentRoom.description} +
+ )} +
+ {ctx.currentRoomId && roomOnlineCount > 0 && ( + + + {trans("chatBoxV2.onlineCount", { count: roomOnlineCount })} + + )} +
+ )} + + + + { + ctx.lastSentMessageText.onChange(text); + ctx.onEvent("messageSent"); + }} + onStartTyping={() => ctx.onEvent("startTyping")} + onStopTyping={() => ctx.onEvent("stopTyping")} + onDraftChange={(text) => ctx.messageText.onChange(text)} + inputStyle={ctx.inputStyle} + /> +
+ + {/* โ”€โ”€ Modals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + setCreateModalOpen(false)} + 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={ctx.currentRoom} + onSendInvite={async (toUserId) => { + ctx.onInviteSend(toUserId); + return true; + }} + /> +
+ ); +}); + +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 0000000000..16771af059 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx @@ -0,0 +1,226 @@ +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"; +import { trans } from "i18n"; + +export interface CreateRoomModalProps { + open: boolean; + onClose: () => void; + 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" | "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, roomMode], + ); + + const handleCancel = useCallback(() => { + onClose(); + form.resetFields(); + setRoomMode("normal"); + }, [onClose, form]); + + return ( + + {/* Room mode selector */} +
+
+ {trans("chatBoxV2.roomTypeLabel")} +
+ + + {trans("chatBoxV2.normalRoomLabel")} +
+ ), + value: "normal", + }, + { + label: ( +
+ + {trans("chatBoxV2.aiRoomLabel")} +
+ ), + value: "llm", + }, + ]} + /> + + + {roomMode === "llm" && ( + } + style={{ + marginBottom: 16, + background: "#faf5ff", + border: "1px solid #e9d5ff", + borderRadius: 8, + }} + message={ + + {trans("chatBoxV2.aiRoomStrongLabel")}{" "} + {trans("chatBoxV2.aiRoomMessage")} + + } + /> + )} + +
+ + + + + + + + + {roomMode === "normal" && ( + + + + {trans("chatBoxV2.publicRoomsLabel")} + + + {trans("chatBoxV2.privateRoomsLabel")} + + + + )} + + {roomMode === "llm" && ( + + {trans("chatBoxV2.queryNameLabel")}{" "} + + ({trans("chatBoxV2.queryNameHint")}) + + + } + rules={[{ required: true, message: trans("chatBoxV2.queryNameRequired") }]} + extra={ + + {trans("chatBoxV2.queryNameExtraPrefix")}{" "} + conversationHistory,{" "} + prompt, and{" "} + roomId {trans("chatBoxV2.queryNameExtraSuffix")} + + } + > + } + /> + + )} + + + + + + + +
+
+ ); +}); + +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 0000000000..1ef5235a51 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx @@ -0,0 +1,107 @@ +import React, { useCallback, useRef, useState } from "react"; +import { Button } from "antd"; +import { SendOutlined } from "@ant-design/icons"; +import type { ChatBoxV2InputStyleType } from "comps/controls/styleControlConstants"; +import { InputBarContainer, StyledTextArea } from "../styles"; +import { trans } from "i18n"; + +export interface InputBarProps { + onSend: (text: string) => void; + onStartTyping: () => void; + onStopTyping: () => void; + onDraftChange: (text: string) => void; + inputStyle?: ChatBoxV2InputStyleType; +} + +export const InputBar = React.memo((props: InputBarProps) => { + const { onSend, onStartTyping, onStopTyping, onDraftChange, inputStyle } = 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(() => { + if (!draft.trim()) return; + handleStopTyping(); + onSend(draft.trim()); + setDraft(""); + onDraftChange(""); + }, [draft, onSend, handleStopTyping, onDraftChange]); + + 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); + onDraftChange(value); + + if (!value.trim()) { + handleStopTyping(); + return; + } + + if (!isTypingRef.current) { + isTypingRef.current = true; + onStartTyping(); + } + + clearTypingTimeout(); + typingTimeoutRef.current = setTimeout(() => { + handleStopTyping(); + }, 2000); + }, + [onStartTyping, handleStopTyping, clearTypingTimeout, onDraftChange], + ); + + const sendBtnStyle: React.CSSProperties = inputStyle ? { + backgroundColor: inputStyle.sendButtonBackground, + borderColor: inputStyle.sendButtonBackground, + color: inputStyle.sendButtonIcon, + } : {}; + + return ( + + + + + + + + + )} + + ); +}); + +InviteUserModal.displayName = "InviteUserModal"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx new file mode 100644 index 0000000000..c9e6f6e675 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx @@ -0,0 +1,208 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +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 type { ChatBoxV2MessageStyleType } from "comps/controls/styleControlConstants"; +import { trans } from "i18n"; +import { + MessagesArea, + MessageWrapper, + Bubble, + BubbleMeta, + BubbleTime, + EmptyChat, + TypingIndicatorWrapper, + TypingDots, + TypingLabel, + AiBubbleWrapper, + AiBadge, + AiBubble, + AiCopyButton, + 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, ts }: { text: string; authorName: string; ts: dayjs.Dayjs | null }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1800); + }); + }, [text]); + + return ( + + + + {authorName} + +
+ + {text} + + + + {copied ? ( + + ) : ( + + )} + + +
+ {ts && ( + + {formatChatTime(ts)} + + )} +
+ ); + }, +); + +AiMessageBubble.displayName = "AiMessageBubble"; + +// โ”€โ”€ Main component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface MessageListProps { + messages: any[]; + typingUsers: any[]; + currentUserId: string; + isAiThinking?: boolean; + messageStyle?: ChatBoxV2MessageStyleType; +} + +export const MessageList = React.memo((props: MessageListProps) => { + const { messages, typingUsers, currentUserId, isAiThinking = false, messageStyle } = props; + const containerRef = useRef(null); + + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: "smooth", + }); + } + }, [messages.length, isAiThinking]); + + return ( + + {messages.length === 0 ? ( + +
๐Ÿ’ฌ
+
{trans("chatBoxV2.noMessagesYet")}
+
{trans("chatBoxV2.startConversation")}
+
+ ) : ( + messages.map((msg, idx) => { + const id = readField(msg, "id", "_id") || `msg_${idx}`; + const text = readField(msg, "text", "message", "content"); + const authorId = readField( + msg, + "authorId", + "userId", + "author_id", + "sender", + ); + const authorName = + readField( + msg, + "authorName", + "userName", + "author_name", + "senderName", + ) || authorId; + const ts = parseMessageTimestamp(msg); + const authorType = msg.authorType || msg.role || ""; + + const isAssistant = + authorType === "assistant" || + authorId === LLM_BOT_AUTHOR_ID; + const isOwn = !isAssistant && authorId === currentUserId; + + if (isAssistant) { + return ( + + ); + } + + return ( + + {authorName} + {text} + {ts && ( + + {formatChatTime(ts)} + + )} + + ); + }) + )} + + {isAiThinking && ( + + + + {trans("chatBoxV2.aiThinking")} + + + + + + + + )} + + {typingUsers.length > 0 && ( + + + + + + + + {typingUsers.length === 1 + ? trans("chatBoxV2.singleUserTyping", { + userName: + typingUsers[0].userName || + typingUsers[0].userId || + trans("chatBoxV2.someoneLabel"), + }) + : trans("chatBoxV2.multipleUsersTyping", { count: typingUsers.length })} + + + )} + +
+ ); +}); + +MessageList.displayName = "MessageList"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx new file mode 100644 index 0000000000..bfac63e065 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx @@ -0,0 +1,404 @@ +import React, { useMemo, useState } from "react"; +import { Button, Input, Tooltip, Popconfirm } from "antd"; +import { + PlusOutlined, + SearchOutlined, + GlobalOutlined, + LockOutlined, + LogoutOutlined, + RobotOutlined, + MailOutlined, + UserAddOutlined, + TeamOutlined, +} from "@ant-design/icons"; +import type { ChatRoom, OnlineUser } from "../store"; +import { + RoomPanelContainer, + RoomPanelHeader, + RoomListContainer, + RoomItemStyled, + SearchResultBadge, + LlmRoomBadge, + OnlinePresenceSection, + OnlinePresenceLabel, + OnlineUserItem, + OnlineAvatar, + OnlineDot, + OnlineUserName, +} from "../styles"; +import { useChatBox } from "../ChatBoxContext"; +import { trans } from "i18n"; + +export interface RoomPanelProps { + onCreateModalOpen: () => void; + onInviteModalOpen?: () => void; +} + +export const RoomPanel = React.memo((props: RoomPanelProps) => { + const { onCreateModalOpen, onInviteModalOpen } = props; + const { + rooms, + currentRoomId, + currentUserId, + currentUserName, + allowRoomCreation, + allowRoomSearch, + roomsPanelWidth, + pendingInvites, + onlineUsers, + sidebarStyle, + onRoomSwitch, + onRoomJoin, + onRoomLeave, + onInviteAccept, + 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); + + 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 = () => { + setSearchQuery(""); + setIsSearchMode(false); + setSearchResults([]); + }; + + const handleJoinAndClear = (roomId: string) => { + onRoomJoin(roomId); + clearSearch(); + }; + + const roomListItems = isSearchMode ? searchResults : rooms; + + 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) { + onRoomSwitch(room.id); + } + }} + title={isSearch ? trans("chatBoxV2.joinRoomTitle", { roomName: room.name }) : room.name} + > + {room.type === "llm" ? ( + + ) : room.type === "public" ? ( + + ) : ( + + )} + + {room.name} + + {room.type === "llm" && !isSearch && ( + + {trans("chatBoxV2.aiShortLabel")} + + )} + {isSearch && {trans("chatBoxV2.joinAction")}} + {isActive && !isSearch && ( + { + e?.stopPropagation(); + onRoomLeave(room.id); + }} + onCancel={(e) => e?.stopPropagation()} + okText={trans("chatBoxV2.leaveAction")} + cancelText={trans("chatBoxV2.cancelAction")} + okButtonProps={{ danger: true }} + > + e.stopPropagation()} + style={{ fontSize: 12, opacity: 0.7 }} + /> + + )} + + ); + }; + + return ( + + + {trans("chatBoxV2.roomsHeader")} +
+ {onInviteModalOpen && ( + +
+
+ + {allowRoomSearch && ( +
+ } + value={searchQuery} + onChange={(e) => handleSearch(e.target.value)} + allowClear + onClear={clearSearch} + /> +
+ )} + + {isSearchMode && ( +
+ {searchResults.length > 0 + ? trans( + searchResults.length === 1 + ? "chatBoxV2.searchResultsCountSingle" + : "chatBoxV2.searchResultsCountPlural", + { count: searchResults.length }, + ) + : trans("chatBoxV2.noPublicRoomsMatch", { searchQuery })} + +
+ )} + + {/* Pending invites section */} + {!isSearchMode && pendingInvites.length > 0 && ( +
+
+ + {trans("chatBoxV2.pendingInvitesHeader", { count: pendingInvites.length })} +
+ {pendingInvites.map((invite) => ( +
+
+ + {invite.roomName} +
+
+ {trans("chatBoxV2.invitedBy", { userName: invite.fromUserName })} +
+
+ + +
+
+ ))} +
+ )} + + + {roomListItems.length === 0 && !isSearchMode && ( +
+ {allowRoomCreation + ? trans("chatBoxV2.noRoomsYetCreateOne") + : trans("chatBoxV2.noRoomsYet")} +
+ )} + + {isSearchMode + ? roomListItems.map(renderRoomItem) + : ( + <> + {llmRooms.length > 0 && ( + <> + + {llmRooms.map(renderRoomItem)} + + )} + {publicRooms.length > 0 && ( + <> + + {publicRooms.map(renderRoomItem)} + + )} + {privateRooms.length > 0 && ( + <> + + {privateRooms.map(renderRoomItem)} + + )} + + )} +
+ + {/* โ”€โ”€ Online Presence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {currentRoomId && roomOnlineUsers.length > 0 && ( + + + + {trans("chatBoxV2.onlinePresence", { count: roomOnlineUsers.length })} + + {roomOnlineUsers.map((user) => ( + + + {(user.userName || user.userId).slice(0, 1).toUpperCase()} + + + + {user.userId === currentUserId + ? trans("chatBoxV2.userWithYou", { userName: user.userName }) + : 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 }) => ( +
+ {label} +
+)); + +RoomSectionLabel.displayName = "RoomSectionLabel"; 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 0000000000..68429247a1 --- /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/store/hocuspocusClient.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/hocuspocusClient.tsx new file mode 100644 index 0000000000..face724bb2 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/hocuspocusClient.tsx @@ -0,0 +1,262 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import * as Y from "yjs"; +import { + HocuspocusProvider, + WebSocketStatus, +} from "@hocuspocus/provider"; + +// โ”€โ”€ Environment config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const WS_URL = REACT_APP_HOCUSPOCUS_URL || "ws://localhost:3006"; + +const AUTH_TOKEN = REACT_APP_HOCUSPOCUS_SECRET || ""; + +type ConnectionState = "connecting" | "open" | "closed"; + +function mapWebSocketStatus(status?: WebSocketStatus): ConnectionState { + switch (status) { + case WebSocketStatus.Connected: + return "open"; + case WebSocketStatus.Connecting: + return "connecting"; + default: + return "closed"; + } +} + +// โ”€โ”€ Context โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface HocuspocusContextValue { + provider: HocuspocusProvider; + doc: Y.Doc; +} + +const HocuspocusContext = createContext(null); + +function useHocuspocusContext(): HocuspocusContextValue { + const ctx = useContext(HocuspocusContext); + if (!ctx) { + throw new Error( + "Hocuspocus hooks must be used inside ", + ); + } + return ctx; +} + +// โ”€โ”€ Provider component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface HocuspocusRoomProviderProps { + /** Document/room name โ€” all clients with the same name share state. */ + room: string; + /** Initial presence fields to set on connect. */ + initialPresence?: Record; + /** Called when auth fails. */ + onAuthenticationFailed?: (error: unknown) => void; + children: React.ReactNode; +} + +export function HocuspocusRoomProvider({ + room, + initialPresence, + onAuthenticationFailed, + children, +}: HocuspocusRoomProviderProps) { + const initialPresenceKey = JSON.stringify(initialPresence ?? null); + + const value = useMemo(() => { + const doc = new Y.Doc(); + const provider = new HocuspocusProvider({ + url: WS_URL, + name: room, + document: doc, + token: AUTH_TOKEN || undefined, + onAuthenticationFailed: (data: unknown) => { + console.error("[Hocuspocus] Auth failed:", data); + onAuthenticationFailed?.(data); + }, + }); + + return { provider, doc }; + // Only re-create when the room name changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [room]); + + useEffect(() => { + if (initialPresenceKey !== "null") { + value.provider.setAwarenessField("user", JSON.parse(initialPresenceKey)); + } + }, [initialPresenceKey, value.provider]); + + useEffect(() => { + return () => { + value.provider.destroy(); + value.doc.destroy(); + }; + }, [value]); + + return ( + + {children} + + ); +} + +// โ”€โ”€ Hook: useConnection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function useConnection(): { state: ConnectionState } { + const { provider } = useHocuspocusContext(); + + const getStatus = useCallback( + () => mapWebSocketStatus(provider.configuration.websocketProvider.status), + [provider], + ); + + const [state, setState] = useState(getStatus); + + useEffect(() => { + // Sync immediately when provider changes + setState(getStatus()); + + const handleStatus = ({ status }: { status: WebSocketStatus }) => { + setState(mapWebSocketStatus(status)); + }; + + provider.on("status", handleStatus); + + return () => { + provider.off("status", handleStatus); + }; + }, [provider, getStatus]); + + return { state }; +} + +// โ”€โ”€ Hook: useMyPresence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function useMyPresence(): [ + Record, + (fields: Record) => void, +] { + const { provider } = useHocuspocusContext(); + + const getPresence = useCallback( + () => provider.awareness?.getLocalState()?.user ?? {}, + [provider], + ); + + const [presence, setPresence] = useState>(getPresence); + + const updatePresence = useCallback( + (fields: Record) => { + provider.setAwarenessField("user", fields); + setPresence(fields); + }, + [provider], + ); + + useEffect(() => { + const awareness = provider.awareness; + if (!awareness) return; + + const handleChange = () => { + setPresence(getPresence()); + }; + + awareness.on("change", handleChange); + + return () => { + awareness.off("change", handleChange); + }; + }, [provider, getPresence]); + + return [presence, updatePresence]; +} + +// โ”€โ”€ Hook: useOthers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface OtherUser { + clientId: number; + presence: Record; +} + +export function useOthers(): OtherUser[] { + const { provider } = useHocuspocusContext(); + + const getOthers = useCallback((): OtherUser[] => { + const awareness = provider.awareness; + if (!awareness) return []; + + const localClientId = awareness.clientID; + const others: OtherUser[] = []; + + awareness.getStates().forEach((state: Record, clientId: number) => { + if (clientId === localClientId) return; + if (state?.user) { + others.push({ clientId, presence: state.user as Record }); + } + }); + + return others; + }, [provider]); + + const [others, setOthers] = useState(getOthers); + + useEffect(() => { + const awareness = provider.awareness; + if (!awareness) return; + + const handleChange = () => { + setOthers(getOthers()); + }; + + awareness.on("change", handleChange); + + return () => { + awareness.off("change", handleChange); + }; + }, [provider, getOthers]); + + return others; +} + +// โ”€โ”€ Hook: useStorage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Returns [snapshot, yMap] for a named top-level Y.Map on the shared doc. +// The snapshot auto-updates on any remote or local mutation. + +export function useStorage( + mapName: string, +): [Record | null, Y.Map | null] { + const { doc } = useHocuspocusContext(); + + const yMap = useMemo(() => doc.getMap(mapName), [doc, mapName]); + + const getSnapshot = useCallback( + () => (yMap ? Object.fromEntries(yMap.entries()) : null), + [yMap], + ); + + const [snapshot, setSnapshot] = useState | null>(getSnapshot); + + useEffect(() => { + if (!yMap) return; + + const handleChange = () => { + setSnapshot(getSnapshot()); + }; + + yMap.observe(handleChange); + + return () => { + yMap.unobserve(handleChange); + }; + }, [yMap, getSnapshot]); + + return [snapshot, yMap]; +} 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 0000000000..9cfc8465b2 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts @@ -0,0 +1,18 @@ +export type { + AiThinkingState, + ChatMessage, + ChatRoom, + PendingRoomInvite, + TypingUser, + OnlineUser, +} from "./types"; + +export { uid, LLM_BOT_AUTHOR_ID } from "./types"; + +export { + HocuspocusRoomProvider, + useStorage, + useMyPresence, + useOthers, + useConnection, +} from "./hocuspocusClient"; 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 0000000000..d26e7f2f32 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts @@ -0,0 +1,54 @@ +export interface ChatMessage { + id: string; + text: string; + authorId: string; + authorName: string; + timestamp: number; + authorType?: "user" | "assistant"; + [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; + roomId?: string; +} + +export interface OnlineUser { + userId: string; + userName: string; + currentRoomId: string | null; +} + +export interface AiThinkingState { + roomId: string; + isThinking: boolean; + timestamp: number; +} + +export const LLM_BOT_AUTHOR_ID = "__llm_bot__"; + +export function uid(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} 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 0000000000..3c01d66835 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts @@ -0,0 +1,486 @@ +import styled from "styled-components"; +import type { + ChatBoxV2ContainerStyleType, + ChatBoxV2SidebarStyleType, + ChatBoxV2HeaderStyleType, + ChatBoxV2MessageStyleType, + ChatBoxV2InputStyleType, + AnimationStyleType, +} from "comps/controls/styleControlConstants"; + +export const Wrapper = styled.div<{ $style: ChatBoxV2ContainerStyleType; $anim: AnimationStyleType }>` + height: 100%; + display: flex; + overflow: hidden; + border-radius: ${(p) => p.$style.radius || "8px"}; + border: ${(p) => p.$style.borderWidth || "1px"} ${(p) => p.$style.borderStyle || "solid"} ${(p) => p.$style.border || "#e0e0e0"}; + background: ${(p) => p.$style.background || "#fff"}; + margin: ${(p) => p.$style.margin || "0"}; + padding: ${(p) => p.$style.padding || "0"}; + ${(p) => p.$anim} +`; + +export const RoomPanelContainer = styled.div<{ + $width: string; + $sidebarStyle?: ChatBoxV2SidebarStyleType; +}>` + width: ${(p) => p.$width}; + min-width: 160px; + border-right: 1px solid ${(p) => p.$sidebarStyle?.sidebarBorder || "#eee"}; + display: flex; + flex-direction: column; + background: ${(p) => p.$sidebarStyle?.sidebarBackground || "#fafbfc"}; + color: ${(p) => p.$sidebarStyle?.sidebarText || "inherit"}; + border-radius: ${(p) => p.$sidebarStyle?.radius || "0"}; +`; + +export const RoomPanelHeader = styled.div<{ $sidebarStyle?: ChatBoxV2SidebarStyleType }>` + padding: 12px; + font-weight: 600; + font-size: 13px; + color: ${(p) => p.$sidebarStyle?.sidebarText || "#555"}; + background: ${(p) => p.$sidebarStyle?.sidebarHeaderBackground || "transparent"}; + border-bottom: 1px solid ${(p) => p.$sidebarStyle?.sidebarBorder || "#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; + $sidebarStyle?: ChatBoxV2SidebarStyleType; +}>` + padding: ${(p) => p.$sidebarStyle?.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 + ? p.$sidebarStyle?.sidebarActiveItemBackground || "#1890ff" + : "#fff"}; + color: ${(p) => + p.$active + ? p.$sidebarStyle?.sidebarActiveItemText || "#fff" + : p.$sidebarStyle?.sidebarText || "#333"}; + border: 1px solid ${(p) => + p.$active + ? p.$sidebarStyle?.sidebarActiveItemBackground || "#1890ff" + : "#f0f0f0"}; + + &:hover { + background: ${(p) => + p.$active + ? p.$sidebarStyle?.sidebarActiveItemBackground || "#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<{ $headerStyle?: ChatBoxV2HeaderStyleType }>` + padding: ${(p) => p.$headerStyle?.padding || "12px 16px"}; + border-bottom: 1px solid ${(p) => p.$headerStyle?.headerBorder || "#eee"}; + background: ${(p) => p.$headerStyle?.headerBackground || "transparent"}; + color: ${(p) => p.$headerStyle?.headerText || "inherit"}; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const MessagesArea = styled.div<{ $messageStyle?: ChatBoxV2MessageStyleType }>` + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + background: ${(p) => p.$messageStyle?.messageAreaBackground || "transparent"}; +`; + +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; + $messageStyle?: ChatBoxV2MessageStyleType; +}>` + padding: ${(p) => p.$messageStyle?.padding || "10px 14px"}; + border-radius: ${(p) => { + const r = p.$messageStyle?.radius; + if (r && r !== "0") return r; + return p.$own ? "16px 16px 4px 16px" : "16px 16px 16px 4px"; + }}; + background: ${(p) => + p.$own + ? p.$messageStyle?.ownMessageBackground || "#1890ff" + : p.$messageStyle?.otherMessageBackground || "#f0f0f0"}; + color: ${(p) => + p.$own + ? p.$messageStyle?.ownMessageText || "#fff" + : p.$messageStyle?.otherMessageText || "#333"}; + font-size: 14px; + word-break: break-word; +`; + +export const BubbleMeta = styled.div<{ + $own: boolean; + $messageStyle?: ChatBoxV2MessageStyleType; +}>` + font-size: 11px; + color: ${(p) => p.$messageStyle?.messageMetaText || "inherit"}; + opacity: ${(p) => (p.$messageStyle?.messageMetaText ? 1 : 0.7)}; + margin-bottom: 2px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const BubbleTime = styled.div<{ + $own: boolean; + $messageStyle?: ChatBoxV2MessageStyleType; +}>` + font-size: 10px; + color: ${(p) => p.$messageStyle?.messageMetaText || "inherit"}; + opacity: ${(p) => (p.$messageStyle?.messageMetaText ? 0.8 : 0.6)}; + margin-top: 4px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const InputBarContainer = styled.div<{ $inputStyle?: ChatBoxV2InputStyleType }>` + padding: 12px 16px; + border-top: 1px solid ${(p) => p.$inputStyle?.inputAreaBorder || "#eee"}; + background: ${(p) => p.$inputStyle?.inputAreaBackground || "transparent"}; + display: flex; + gap: 8px; + align-items: flex-end; +`; + +export const StyledTextArea = styled.textarea<{ $inputStyle?: ChatBoxV2InputStyleType }>` + flex: 1; + padding: ${(p) => p.$inputStyle?.padding || "8px 14px"}; + border: 1px solid ${(p) => p.$inputStyle?.inputBorder || "#d9d9d9"}; + border-radius: ${(p) => p.$inputStyle?.radius || "18px"}; + background: ${(p) => p.$inputStyle?.inputBackground || "#fff"}; + color: ${(p) => p.$inputStyle?.inputText || "inherit"}; + resize: none; + min-height: 36px; + max-height: 96px; + font-size: 14px; + outline: none; + font-family: inherit; + line-height: 1.4; + &:focus { + border-color: ${(p) => p.$inputStyle?.sendButtonBackground || "#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"}; +`; + +// โ”€โ”€ LLM / AI message styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const AiBubbleWrapper = styled.div` + display: flex; + flex-direction: column; + align-self: flex-start; + max-width: 80%; + position: relative; + + &:hover .ai-copy-btn { + opacity: 1; + } +`; + +export const AiBadge = styled.span` + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + font-weight: 600; + color: #7c3aed; + background: #f3e8ff; + border-radius: 8px; + padding: 2px 7px; + margin-bottom: 4px; + align-self: flex-start; + letter-spacing: 0.4px; + text-transform: uppercase; +`; + +export const AiBubble = styled.div` + background: #faf5ff; + border: 1px solid #e9d5ff; + border-radius: 4px 16px 16px 16px; + padding: 10px 14px; + font-size: 14px; + color: #1f1f1f; + line-height: 1.6; + word-break: break-word; + + p { margin: 0 0 8px; } + p:last-child { margin-bottom: 0; } + pre { + background: #f1f5f9; + border-radius: 6px; + padding: 10px 12px; + overflow-x: auto; + font-size: 13px; + } + code { + background: #f1f5f9; + border-radius: 3px; + padding: 1px 5px; + font-size: 13px; + font-family: "Fira Mono", "Cascadia Code", monospace; + } + pre code { + background: none; + padding: 0; + } + ul, ol { padding-left: 20px; margin: 6px 0; } + li { margin-bottom: 2px; } + blockquote { + border-left: 3px solid #c084fc; + margin: 6px 0; + padding-left: 10px; + color: #666; + } + a { color: #7c3aed; } + strong { font-weight: 600; } + h1, h2, h3, h4 { margin: 8px 0 4px; font-weight: 600; } + table { border-collapse: collapse; width: 100%; margin: 6px 0; } + th, td { border: 1px solid #e9d5ff; padding: 4px 8px; } + th { background: #f3e8ff; } +`; + +export const AiCopyButton = styled.button` + position: absolute; + top: 28px; + right: -34px; + width: 26px; + height: 26px; + border: none; + background: #f3e8ff; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s ease, background 0.15s ease; + color: #7c3aed; + font-size: 13px; + + &:hover { + background: #e9d5ff; + } +`; + +export const LlmLoadingBubble = styled.div` + align-self: flex-start; + background: #faf5ff; + border: 1px solid #e9d5ff; + border-radius: 4px 16px 16px 16px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 5px; + + span { + width: 7px; + height: 7px; + border-radius: 50%; + background: #c084fc; + animation: llmThink 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 llmThink { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1.1); opacity: 1; } + } +`; + +export const LlmRoomBadge = styled.span` + font-size: 10px; + font-weight: 600; + color: #7c3aed; + background: #f3e8ff; + border-radius: 6px; + 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; +`; 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 0000000000..98ed240d5d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -0,0 +1,14 @@ +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// DEPRECATED โ€” This hook is no longer used. +// +// Architecture change (v2): +// โ€ข 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 ChatControllerSignal. +// Data storage is handled by the user's own Data Sources & Queries. +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export {}; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index 57ac9040a1..39de2e7393 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -4,19 +4,32 @@ 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"; import QuerySelectControl from "comps/controls/querySelectControl"; import { eventHandlerControl, EventConfigType } from "comps/controls/eventHandlerControl"; -import { ChatCore } from "./components/ChatCore"; +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"; +import { QueryHandler } from "./handlers/messageHandlers"; 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, + ChatSidebarStyle, + ChatMessagesStyle, + ChatInputStyle, + ChatSendButtonStyle, + ChatNewThreadButtonStyle, + ChatThreadItemStyle, +} from "comps/controls/styleControlConstants"; +import { AnimationStyle } from "comps/controls/styleControlConstants"; import "@assistant-ui/styles/index.css"; import "@assistant-ui/styles/markdown.css"; @@ -128,38 +141,48 @@ 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")), + // Layout Configuration + autoHeight: AutoHeightControl, + leftPanelWidth: withDefault(StringControl, "250px"), + // Database Information (read-only) databaseName: withDefault(StringControl, ""), // Event Handlers onEvent: ChatEventHandlerControl, + // 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), + // 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[]), }; // ============================================================================ -// CLEAN CHATCOMP - USES NEW ARCHITECTURE +// CHATCOMP // ============================================================================ const ChatTmpComp = new UICompBuilder( @@ -187,64 +210,44 @@ 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, ]); // Handle message updates for exposed variable + // Using Lowcoder pattern: props.currentMessage.onChange() 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(() => { @@ -256,27 +259,53 @@ const handleConversationUpdate = (conversationHistory: any[]) => { }; }, []); + // custom styles + 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 ( - + + + + + ); } ) .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 +// EXPOSED VARIABLES // ============================================================================ -export const ChatComp = withExposingConfigs(ChatTmpComp, [ +export const ChatComp = withExposingConfigs(ChatCompWithAutoHeight, [ 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 diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts index 3151bff6ad..9bb53a72a1 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts @@ -1,19 +1,16 @@ // client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts // ============================================================================ -// CLEAN CHATCOMP TYPES - SIMPLIFIED AND FOCUSED +// CHATCOMP TYPES // ============================================================================ 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 0e2fd02901..b12aafd41d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx @@ -2,11 +2,12 @@ import React, { useMemo } from "react"; import { Section, sectionNames, DocLink } from "lowcoder-design"; -import { placeholderPropertyView } from "../../utils/propertyUtils"; import { trans } from "i18n"; +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) => { @@ -27,56 +28,69 @@ 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 */}
- {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 */}
- {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 */} @@ -84,6 +98,39 @@ 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.threadItemStyle.getPropertyView()} +
+ +
+ {children.animationStyle.getPropertyView()} +
+ ), [children]); }); diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx similarity index 63% rename from client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx rename to client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx index d5b0ce187c..689e0dc289 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx @@ -1,6 +1,6 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +// 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, @@ -18,94 +18,43 @@ import { RegularThreadData, ArchivedThreadData } from "./context/ChatContext"; -import { MessageHandler, ChatMessage } from "../types/chatTypes"; -import styled from "styled-components"; +import { MessageHandler, ChatMessage, ChatCoreProps } from "../types/chatTypes"; import { trans } from "i18n"; import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; +import { StyledChatContainer } from "./ChatContainerStyles"; // ============================================================================ -// STYLED COMPONENTS (same as your current ChatMain) +// CHAT CONTAINER // ============================================================================ -const ChatContainer = styled.div` - display: flex; - height: 500px; - - p { - margin: 0; - } - - .aui-thread-list-root { - width: 250px; - background-color: #fff; - padding: 10px; - } - - .aui-thread-root { - flex: 1; - background-color: #f9fafb; - } - - .aui-thread-list-item { - cursor: pointer; - transition: background-color 0.2s ease; - - &[data-active="true"] { - background-color: #dbeafe; - border: 1px solid #bfdbfe; - } - } -`; - -// ============================================================================ -// CHAT CORE MAIN - CLEAN PROPS, FOCUSED RESPONSIBILITY -// ============================================================================ - -interface ChatCoreMainProps { - messageHandler: MessageHandler; - placeholder?: string; - onMessageUpdate?: (message: string) => void; - onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; - // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK (OPTIONAL) - onEvent?: (eventName: string) => void; -} - const generateId = () => Math.random().toString(36).substr(2, 9); -export function ChatCoreMain({ - messageHandler, - placeholder, - onMessageUpdate, - onConversationUpdate, - onEvent -}: ChatCoreMainProps) { +function ChatContainerView(props: ChatCoreProps) { const { state, actions } = useChatContext(); const [isRunning, setIsRunning] = useState(false); - console.log("RENDERING CHAT CORE MAIN"); + // callback props in refs so useEffects don't re-fire + const onConversationUpdateRef = useRef(props.onConversationUpdate); + onConversationUpdateRef.current = props.onConversationUpdate; + + const onEventRef = useRef(props.onEvent); + onEventRef.current = props.onEvent; - // 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); + onConversationUpdateRef.current?.(currentMessages); } }, [currentMessages, isRunning]); - // Trigger component load event on mount useEffect(() => { - onEvent?.("componentLoad"); - }, [onEvent]); + onEventRef.current?.("componentLoad"); + }, []); - // 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) { @@ -123,22 +72,17 @@ export function ChatCoreMain({ }; }; - // 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) { + if (!text && !completeAttachments.length) { throw new Error("Cannot send an empty message"); } @@ -154,9 +98,8 @@ export function ChatCoreMain({ setIsRunning(true); try { - const response = await messageHandler.sendMessage(userMessage); // Send full message object with attachments - - onMessageUpdate?.(userMessage.text); + const response = await props.messageHandler.sendMessage(userMessage); + props.onMessageUpdate?.(userMessage.text); const assistantMessage: ChatMessage = { id: generateId(), @@ -167,48 +110,34 @@ export function ChatCoreMain({ 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 - 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) { + if (!text && !completeAttachments.length) { 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", @@ -218,15 +147,12 @@ export function ChatCoreMain({ }; 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 response = await props.messageHandler.sendMessage(editedMessage); + props.onMessageUpdate?.(editedMessage.text); const assistantMessage: ChatMessage = { id: generateId(), @@ -238,21 +164,18 @@ export function ChatCoreMain({ 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 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"), @@ -261,7 +184,7 @@ export function ChatCoreMain({ onSwitchToNewThread: async () => { const threadId = await actions.createThread(trans("chat.newChatTitle")); actions.setCurrentThread(threadId); - onEvent?.("threadCreated"); + props.onEvent?.("threadCreated"); }, onSwitchToThread: (threadId) => { @@ -270,25 +193,23 @@ export function ChatCoreMain({ onRename: async (threadId, newTitle) => { await actions.updateThread(threadId, { title: newTitle }); - onEvent?.("threadUpdated"); + props.onEvent?.("threadUpdated"); }, onArchive: async (threadId) => { await actions.updateThread(threadId, { status: "archived" }); - onEvent?.("threadUpdated"); + props.onEvent?.("threadUpdated"); }, onDelete: async (threadId) => { await actions.deleteThread(threadId); - onEvent?.("threadDeleted"); + props.onEvent?.("threadDeleted"); }, }; const runtime = useExternalStoreRuntime({ messages: currentMessages, - setMessages: (messages) => { - actions.updateMessages(state.currentThreadId, messages); - }, + setMessages: (messages) => actions.updateMessages(state.currentThreadId, messages), convertMessage, isRunning, onNew, @@ -305,11 +226,27 @@ export function ChatCoreMain({ return ( - + - - + + ); } +// ============================================================================ +// EXPORT +// ============================================================================ + +export const ChatContainer = ChatContainerView; 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 0000000000..1f2d4580db --- /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/ChatCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx deleted file mode 100644 index ad0d33e2cf..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx +++ /dev/null @@ -1,34 +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, - onMessageUpdate, - onConversationUpdate, - onEvent -}: ChatCoreProps) { - return ( - - - - - - ); -} \ No newline at end of file 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 1c9af4f55b..f4823011e6 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx @@ -1,7 +1,7 @@ // client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx -import { useMemo } from "react"; -import { ChatCore } from "./ChatCore"; +import { useMemo, useEffect } from "react"; +import { ChatPanelContainer } from "./ChatPanelContainer"; import { createChatStorage } from "../utils/storageFactory"; import { N8NHandler } from "../handlers/messageHandlers"; import { ChatPanelProps } from "../types/chatTypes"; @@ -11,7 +11,7 @@ import "@assistant-ui/styles/index.css"; import "@assistant-ui/styles/markdown.css"; // ============================================================================ -// CHAT PANEL - CLEAN BOTTOM PANEL COMPONENT +// CHAT PANEL - SIMPLIFIED BOTTOM PANEL (NO STYLING CONTROLS) // ============================================================================ export function ChatPanel({ @@ -21,24 +21,29 @@ export function ChatPanel({ streaming = true, onMessageUpdate }: ChatPanelProps) { - // Create storage instance - const storage = useMemo(() => - createChatStorage(tableName), + const storage = useMemo(() => + createChatStorage(tableName), [tableName] ); - - // Create N8N message handler - 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 ( - ` + 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 CONTAINER - DIRECT RENDERING +// ============================================================================ + +const generateId = () => Math.random().toString(36).substr(2, 9); + +export interface ChatPanelContainerProps { + storage: any; + messageHandler: MessageHandler; + placeholder?: string; + onMessageUpdate?: (message: string) => void; +} + +function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit) { + const { state, actions } = useChatContext(); + const [isRunning, setIsRunning] = useState(false); + + const currentMessages = actions.getCurrentMessages(); + + const convertMessage = (message: ChatMessage): ThreadMessageLike => { + const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; + + return { + role: message.role, + content, + id: message.id, + createdAt: new Date(message.timestamp), + }; + }; + + const onNew = async (message: AppendMessage) => { + const textPart = (message.content as ThreadUserContentPart[]).find( + (part): part is TextContentPart => part.type === "text" + ); + + const text = textPart?.text?.trim() ?? ""; + + if (!text) { + throw new Error("Cannot send an empty message"); + } + + const userMessage: ChatMessage = { + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + }; + + await actions.addMessage(state.currentThreadId, userMessage); + setIsRunning(true); + + try { + const response = await messageHandler.sendMessage(userMessage); + onMessageUpdate?.(userMessage.text); + + await actions.addMessage(state.currentThreadId, { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }); + } 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() ?? ""; + + if (!text) { + throw new Error("Cannot send an empty message"); + } + + const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; + const newMessages = [...currentMessages.slice(0, index)]; + + newMessages.push({ + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + }); + + await actions.updateMessages(state.currentThreadId, newMessages); + setIsRunning(true); + + try { + const response = await messageHandler.sendMessage(newMessages[newMessages.length - 1]); + onMessageUpdate?.(text); + + newMessages.push({ + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }); + 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); + }, + + onSwitchToThread: (threadId) => { + actions.setCurrentThread(threadId); + }, + + onRename: async (threadId, newTitle) => { + await actions.updateThread(threadId, { title: newTitle }); + }, + + onArchive: async (threadId) => { + await actions.updateThread(threadId, { status: "archived" }); + }, + + onDelete: async (threadId) => { + await actions.deleteThread(threadId); + }, + }; + + const runtime = useExternalStoreRuntime({ + messages: currentMessages, + setMessages: (messages) => actions.updateMessages(state.currentThreadId, messages), + convertMessage, + isRunning, + onNew, + onEdit, + adapters: { + threadList: threadListAdapter, + // No attachments support for bottom panel chat + }, + }); + + if (!state.isInitialized) { + return
Loading...
; + } + + return ( + + + + + + + ); +} + +// ============================================================================ +// EXPORT - WITH PROVIDERS +// ============================================================================ + +export function ChatPanelContainer({ storage, messageHandler, placeholder, onMessageUpdate }: ChatPanelContainerProps) { + return ( + + + + + + ); +} 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 616acc0879..a45e5fe147 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 - - - - - ); }; 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 1a31222a9a..e733727f38 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]); 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 4406b74e67..945783c696 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 5e757f2314..d24e0ce84f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -63,28 +63,38 @@ export interface ChatMessage { export interface QueryHandlerConfig { chatQuery: string; dispatch: any; - streaming?: boolean; - systemPrompt?: string; - } - - // ============================================================================ - // COMPONENT PROPS (what each component actually needs) - // ============================================================================ - - export interface ChatCoreProps { - storage: ChatStorage; - messageHandler: MessageHandler; - placeholder?: string; - onMessageUpdate?: (message: string) => void; - onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; - // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK - onEvent?: (eventName: string) => void; } - export interface ChatPanelProps { - tableName: string; - modelHost: string; - systemPrompt?: string; - streaming?: boolean; - onMessageUpdate?: (message: string) => void; - } +// ============================================================================ +// COMPONENT PROPS (what each component actually needs) +// ============================================================================ + +// Main Chat Component Props (with full styling support) +export interface ChatCoreProps { + 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 (only for main component) + style?: any; + sidebarStyle?: any; + messagesStyle?: any; + inputStyle?: any; + sendButtonStyle?: any; + newThreadButtonStyle?: any; + threadItemStyle?: any; + animationStyle?: any; +} + +// Bottom Panel Props (simplified, no styling controls) +export interface ChatPanelProps { + tableName: string; + modelHost: string; + systemPrompt?: string; + streaming?: boolean; + onMessageUpdate?: (message: string) => void; +} 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 a0f7c78e0b..9ff22d4364 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts @@ -5,25 +5,21 @@ 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 + 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) { + 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).` + ); } return { @@ -33,33 +29,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) { + const errorMessage = `Failed to process attachment "${attachment.name}": ${err instanceof Error ? err.message : "unknown error"}`; + messageInstance.error(errorMessage); + throw new Error(errorMessage); + } + return { ...attachment, content, status: { - type: "complete" - } + type: "complete", + }, }; }, diff --git a/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx b/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx index ea6f37f5a3..c6aae7ad24 100644 --- a/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx @@ -139,7 +139,6 @@ const timeValidationFields = (children: CommonChildrenType, dateType: PickerMode function validate( props: RecordConstructorToView & { value: { value: string }; - showTime: boolean; } ): { validateStatus: "success" | "warning" | "error"; @@ -763,7 +762,7 @@ export let DateRangeComp = withExposingConfigs(dateRangeControl, [ depsConfig({ name: "invalid", desc: trans("export.invalidDesc"), - depKeys: ["start", "end", "required", "minTime", "maxTime", "minDate", "maxDate", "customRule"], + depKeys: ["start", "end", "showValidationWhenEmpty", "required", "minTime", "maxTime", "minDate", "maxDate", "customRule"], func: (input) => validate({ ...input, @@ -818,4 +817,4 @@ DateRangeComp = withMethodExposing(DateRangeComp, [ comp.children.end.getView().onChange(data.end); }, }, -]); \ No newline at end of file +]); diff --git a/client/packages/lowcoder/src/comps/comps/dateComp/timeComp.tsx b/client/packages/lowcoder/src/comps/comps/dateComp/timeComp.tsx index 098d68593b..c5b8ecc6b6 100644 --- a/client/packages/lowcoder/src/comps/comps/dateComp/timeComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/dateComp/timeComp.tsx @@ -585,7 +585,7 @@ export let TimeRangeComp = withExposingConfigs(timeRangeControl, [ depsConfig({ name: "invalid", desc: trans("export.invalidDesc"), - depKeys: ["start", "end", "required", "minTime", "maxTime", "customRule"], + depKeys: ["start", "end", "showValidationWhenEmpty", "required", "minTime", "maxTime", "customRule"], func: (input) => validate({ ...input, @@ -640,4 +640,4 @@ TimeRangeComp = withMethodExposing(TimeRangeComp, [ comp.children.end.getView().onChange(data.end); }, }, -]); \ No newline at end of file +]); diff --git a/client/packages/lowcoder/src/comps/controls/colorControl.tsx b/client/packages/lowcoder/src/comps/controls/colorControl.tsx index 6b45a982da..1c56607f85 100644 --- a/client/packages/lowcoder/src/comps/controls/colorControl.tsx +++ b/client/packages/lowcoder/src/comps/controls/colorControl.tsx @@ -35,7 +35,7 @@ const ColorInput = styled.div` const DepStyle = styled.div` left: 30px; width: 140px; - min-height: 30px; + height: 30px; display: flex; align-items: center; padding: 4px 8px; @@ -48,6 +48,11 @@ const DepStyle = styled.div` span:nth-of-type(1) { color: #8b8fa3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + max-width: 100%; } span:nth-of-type(2) { diff --git a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx index 176afbbfc9..3180807974 100644 --- a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx +++ b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx @@ -2372,6 +2372,156 @@ 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 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; @@ -2490,6 +2640,211 @@ 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 type ChatThreadItemStyleType = StyleConfigType; + +// โ”€โ”€โ”€ ChatBox V2 Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const ChatBoxV2ContainerStyle = [ + getBackground(), + BORDER, + BORDER_STYLE, + RADIUS, + BORDER_WIDTH, + MARGIN, + PADDING, +] as const; + +export const ChatBoxV2SidebarStyle = [ + { + name: "sidebarBackground", + label: trans("style.chatV2SidebarBackground"), + color: "#fafbfc", + }, + { + name: "sidebarText", + label: trans("style.chatV2SidebarText"), + depName: "sidebarBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, + { + name: "sidebarBorder", + label: trans("style.chatV2SidebarBorder"), + color: "#eeeeee", + }, + { + name: "sidebarHeaderBackground", + label: trans("style.chatV2SidebarHeaderBackground"), + depName: "sidebarBackground", + depType: DEP_TYPE.SELF, + transformer: toSelf, + }, + { + name: "sidebarActiveItemBackground", + label: trans("style.chatV2ActiveItemBackground"), + depTheme: "primary", + depType: DEP_TYPE.SELF, + transformer: toSelf, + }, + { + name: "sidebarActiveItemText", + label: trans("style.chatV2ActiveItemText"), + depName: "sidebarActiveItemBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, + { + name: "padding", + label: trans("style.chatV2SidebarItemPadding"), + padding: "padding", + }, + { + name: "radius", + label: trans("style.chatV2SidebarRadius"), + radius: "radius", + }, +] as const; + +export const ChatBoxV2HeaderStyle = [ + { + name: "headerBackground", + label: trans("style.chatV2HeaderBackground"), + color: "#ffffff", + }, + { + name: "headerText", + label: trans("style.chatV2HeaderText"), + depName: "headerBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, + { + name: "headerBorder", + label: trans("style.chatV2HeaderBorder"), + color: "#eeeeee", + }, + { + name: "padding", + label: trans("style.chatV2HeaderPadding"), + padding: "padding", + }, +] as const; + +export const ChatBoxV2MessageStyle = [ + { + name: "messageAreaBackground", + label: trans("style.chatV2MessageAreaBackground"), + color: "#ffffff", + }, + { + name: "ownMessageBackground", + label: trans("style.chatV2OwnMessageBackground"), + depTheme: "primary", + depType: DEP_TYPE.SELF, + transformer: toSelf, + }, + { + name: "ownMessageText", + label: trans("style.chatV2OwnMessageText"), + depName: "ownMessageBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, + { + name: "otherMessageBackground", + label: trans("style.chatV2OtherMessageBackground"), + color: "#f0f0f0", + }, + { + name: "otherMessageText", + label: trans("style.chatV2OtherMessageText"), + depName: "otherMessageBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, + { + name: "padding", + label: trans("style.chatV2MessageBubblePadding"), + padding: "padding", + }, + { + name: "radius", + label: trans("style.chatV2MessageBubbleRadius"), + radius: "radius", + }, + { + name: "messageMetaText", + label: trans("style.chatV2MessageMetaText"), + color: "#999999", + }, +] as const; + +export const ChatBoxV2InputStyle = [ + { + name: "inputBackground", + label: trans("style.chatV2InputBackground"), + color: "#ffffff", + }, + { + name: "inputText", + label: trans("style.chatV2InputText"), + depName: "inputBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, + { + name: "inputBorder", + label: trans("style.chatV2InputBorder"), + color: "#d9d9d9", + }, + { + name: "radius", + label: trans("style.chatV2InputRadius"), + radius: "radius", + }, + { + name: "padding", + label: trans("style.chatV2InputPadding"), + padding: "padding", + }, + { + name: "inputAreaBackground", + label: trans("style.chatV2InputAreaBackground"), + color: "#ffffff", + }, + { + name: "inputAreaBorder", + label: trans("style.chatV2InputAreaBorder"), + color: "#eeeeee", + }, + { + name: "sendButtonBackground", + label: trans("style.chatV2SendButtonBackground"), + depTheme: "primary", + depType: DEP_TYPE.SELF, + transformer: toSelf, + }, + { + name: "sendButtonIcon", + label: trans("style.chatV2SendButtonIcon"), + depName: "sendButtonBackground", + depType: DEP_TYPE.CONTRAST_TEXT, + transformer: contrastText, + }, +] as const; + +export type ChatBoxV2ContainerStyleType = StyleConfigType; +export type ChatBoxV2SidebarStyleType = StyleConfigType; +export type ChatBoxV2HeaderStyleType = StyleConfigType; +export type ChatBoxV2MessageStyleType = StyleConfigType; +export type ChatBoxV2InputStyleType = 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/comps/hooks/chatControllerV2Comp.tsx b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx new file mode 100644 index 0000000000..c1d68bd557 --- /dev/null +++ b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx @@ -0,0 +1,691 @@ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { Section, sectionNames } from "lowcoder-design"; +import { + simpleMultiComp, + stateComp, + withPropertyViewFn, + withViewFn, +} from "../generators"; +import { NameConfig, withExposingConfigs } from "../generators/withExposing"; +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 } from "lodash"; +import { trans } from "i18n"; +import { + HocuspocusRoomProvider, + useStorage, + useMyPresence, + useOthers, + useConnection, +} from "../comps/chatBoxComponentv2/store"; +import type { + AiThinkingState, + OnlineUser, + TypingUser, +} from "../comps/chatBoxComponentv2/store"; + +// โ”€โ”€โ”€ Event definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const ChatControllerEvents = [ + { + label: trans("chatControllerSignal.userJoined"), + value: "userJoined", + description: trans("chatControllerSignal.userJoinedDesc"), + }, + { + label: trans("chatControllerSignal.userLeft"), + value: "userLeft", + description: trans("chatControllerSignal.userLeftDesc"), + }, + { + label: trans("chatControllerSignal.roomSwitched"), + value: "roomSwitched", + description: trans("chatControllerSignal.roomSwitchedDesc"), + }, + { + label: trans("chatControllerSignal.connected"), + value: "connected", + description: trans("chatControllerSignal.connectedDesc"), + }, + { + label: trans("chatControllerSignal.disconnected"), + value: "disconnected", + description: trans("chatControllerSignal.disconnectedDesc"), + }, + { + label: trans("chatControllerSignal.error"), + value: "error", + description: trans("chatControllerSignal.errorDesc"), + }, + { + label: trans("chatControllerSignal.aiThinkingStarted"), + value: "aiThinkingStarted", + description: trans("chatControllerSignal.aiThinkingStartedDesc"), + }, + { + label: trans("chatControllerSignal.aiThinkingStopped"), + value: "aiThinkingStopped", + description: trans("chatControllerSignal.aiThinkingStoppedDesc"), + }, + { + label: trans("chatControllerSignal.sharedStateChanged"), + value: "sharedStateChanged", + description: trans("chatControllerSignal.sharedStateChangedDesc"), + }, + { + label: trans("chatControllerSignal.roomDataChanged"), + value: "roomDataChanged", + description: trans("chatControllerSignal.roomDataChangedDesc"), + }, +] as const; + +// โ”€โ”€โ”€ Children map โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const childrenMap = { + applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), + userId: stringExposingStateControl("userId", "user_1"), + userName: stringExposingStateControl("userName", trans("chatControllerSignal.userNameDefault")), + + onEvent: eventHandlerControl(ChatControllerEvents), + + ready: stateComp(false), + error: stateComp(null), + connectionStatus: stateComp(trans("chatControllerSignal.connectingStatus")), + onlineUsers: stateComp([]), + typingUsers: stateComp([]), + currentRoomId: stateComp(null), + aiThinkingRooms: stateComp({}), + sharedState: stateComp({}), + roomData: stateComp({}), + + _signalActions: stateComp({}), +}; + +// โ”€โ”€โ”€ Signal actions interface โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface SignalActions { + 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 Hocuspocus hooks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface SignalControllerProps { + comp: any; + userId: string; + userName: string; +} + +const SignalController = React.memo( + ({ comp, userId, userName }: SignalControllerProps) => { + const connection = useConnection(); + const [, setMyPresence] = useMyPresence(); + const others = useOthers(); + const [aiActivity, aiActivityYMap] = useStorage("aiActivity"); + const [sharedStateData, sharedStateYMap] = useStorage("sharedState"); + const [roomDataData, roomDataYMap] = useStorage("roomData"); + + 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; + onlineCount: number; + onlineInitialized: boolean; + sharedStateInitialized: boolean; + roomDataInitialized: boolean; + aiThinkingInitialized: boolean; + aiThinkingRooms: Record; + }>({ + ready: false, + onlineCount: 0, + onlineInitialized: false, + sharedStateInitialized: false, + roomDataInitialized: false, + aiThinkingInitialized: false, + aiThinkingRooms: {}, + }); + + // โ”€โ”€ Connection state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const ready = connection.state === "open"; + const connectionLabel = useMemo(() => { + if (connection.state === "open") return trans("chatControllerSignal.onlineStatus"); + if (connection.state === "connecting") return trans("chatControllerSignal.connectingStatus"); + return trans("chatControllerSignal.offlineStatus"); + }, [connection.state]); + + useEffect(() => { + compRef.current.children.ready.dispatchChangeValueAction(ready); + compRef.current.children.connectionStatus.dispatchChangeValueAction(connectionLabel); + if (ready) { + compRef.current.children.error.dispatchChangeValueAction(null); + } + if (ready && !prevRef.current.ready) { + triggerEventRef.current("connected"); + } + if (!ready && prevRef.current.ready) { + triggerEventRef.current("disconnected"); + } + prevRef.current.ready = ready; + }, [ready, connectionLabel]); + + // โ”€โ”€ Online users โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const onlineUsers = useMemo(() => { + return others + .filter((o: any) => o.presence != null) + .map((o: any) => ({ + userId: o.presence.userId as string, + userName: o.presence.userName as string, + currentRoomId: (o.presence.currentRoomId as string) || null, + })); + }, [others]); + + useEffect(() => { + compRef.current.children.onlineUsers.dispatchChangeValueAction( + onlineUsers as unknown as JSONObject[], + ); + if (prevRef.current.onlineInitialized) { + if (onlineUsers.length > prevRef.current.onlineCount) { + triggerEventRef.current("userJoined"); + } else if (onlineUsers.length < prevRef.current.onlineCount) { + triggerEventRef.current("userLeft"); + } + } + prevRef.current.onlineCount = onlineUsers.length; + prevRef.current.onlineInitialized = true; + }, [onlineUsers]); + + // โ”€โ”€ Typing users โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const currentRoomId = comp.children.currentRoomId.getView() as string | null; + + const typingUsers = useMemo(() => { + return others + .filter((o: any) => { + if (!o.presence?.typing) return false; + if (o.presence.userId === userId) return false; + if (currentRoomId && o.presence.currentRoomId !== currentRoomId) + return false; + return true; + }) + .map((o: any) => ({ + userId: o.presence.userId as string, + userName: o.presence.userName as string, + roomId: o.presence.currentRoomId as string, + })); + }, [others, currentRoomId, userId]); + + useEffect(() => { + compRef.current.children.typingUsers.dispatchChangeValueAction( + typingUsers as unknown as JSONObject[], + ); + }, [typingUsers]); + + // โ”€โ”€ 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; + if (!prevRef.current.aiThinkingInitialized) { + continue; + } + + const prev = prevRef.current.aiThinkingRooms[roomId] ?? false; + if (state.isThinking && !prev) { + triggerEventRef.current("aiThinkingStarted"); + } else if (!state.isThinking && prev) { + triggerEventRef.current("aiThinkingStopped"); + } + } + + if (prevRef.current.aiThinkingInitialized) { + for (const [roomId, wasThinking] of Object.entries(prevRef.current.aiThinkingRooms)) { + if (wasThinking && !(roomId in nextThinking)) { + triggerEventRef.current("aiThinkingStopped"); + } + } + } + + prevRef.current.aiThinkingRooms = nextThinking; + prevRef.current.aiThinkingInitialized = true; + compRef.current.children.aiThinkingRooms.dispatchChangeValueAction( + nextThinking as unknown as JSONObject, + ); + }, [aiActivity]); + + // โ”€โ”€ Watch shared state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + useEffect(() => { + if (!sharedStateData) return; + compRef.current.children.sharedState.dispatchChangeValueAction( + sharedStateData as unknown as JSONObject, + ); + if (prevRef.current.sharedStateInitialized) { + triggerEventRef.current("sharedStateChanged"); + } + prevRef.current.sharedStateInitialized = true; + }, [sharedStateData]); + + // โ”€โ”€ Watch room data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + useEffect(() => { + if (!roomDataData) return; + compRef.current.children.roomData.dispatchChangeValueAction( + roomDataData as unknown as JSONObject, + ); + if (prevRef.current.roomDataInitialized) { + triggerEventRef.current("roomDataChanged"); + } + prevRef.current.roomDataInitialized = true; + }, [roomDataData]); + + // โ”€โ”€ Actions for method invocation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const startTyping = useCallback( + (roomId?: string) => { + setMyPresence({ + userId, + userName, + currentRoomId: roomId || currentRoomId || null, + typing: true, + }); + }, + [setMyPresence, userId, userName, currentRoomId], + ); + + const stopTyping = useCallback(() => { + setMyPresence({ + userId, + userName, + currentRoomId: currentRoomId, + typing: false, + }); + }, [setMyPresence, userId, userName, currentRoomId]); + + const switchRoom = useCallback( + (roomId: string) => { + compRef.current.children.currentRoomId.dispatchChangeValueAction(roomId); + setMyPresence({ + userId, + userName, + currentRoomId: roomId, + typing: false, + }); + triggerEventRef.current("roomSwitched"); + }, + [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], + ); + + // โ”€โ”€ 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({ + startTyping, + stopTyping, + switchRoom, + setAiThinking, + setSharedState, + deleteSharedState, + setRoomData, + deleteRoomData, + }); + actionsRef.current = { + startTyping, + stopTyping, + switchRoom, + setAiThinking, + setSharedState, + deleteSharedState, + setRoomData, + deleteRoomData, + }; + + useEffect(() => { + const proxy: SignalActions = { + 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, + ); + }, []); + + // โ”€โ”€ Set / restore presence on connect or peer changes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + useEffect(() => { + if (!ready) return; + const roomId = compRef.current.children.currentRoomId.getView() as string | null; + setMyPresence({ + userId, + userName, + currentRoomId: roomId, + typing: false, + }); + }, [ready, others.length, setMyPresence, userId, userName]); + + return null; + }, +); + +SignalController.displayName = "SignalController"; + +// โ”€โ”€โ”€ View function (wraps HocuspocusRoomProvider) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const ChatControllerSignalBase = 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 roomName = `signal_${applicationId || "lowcoder_app"}`; + + return ( + { + console.error("[ChatControllerV2] Auth failed:", error); + comp.children.error.dispatchChangeValueAction( + error?.reason || error?.message || trans("chatControllerSignal.authenticationFailed"), + ); + comp.children.onEvent.getView()("error"); + }} + > + + + ); + }, +); + +// โ”€โ”€โ”€ Property panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const ChatControllerSignalWithProps = withPropertyViewFn( + ChatControllerSignalBase, + (comp) => ( + <> +
+ {comp.children.applicationId.propertyView({ + label: trans("chatControllerSignal.applicationIdLabel"), + tooltip: trans("chatControllerSignal.applicationIdTooltip"), + })} + {comp.children.userId.propertyView({ + label: trans("chatControllerSignal.userIdLabel"), + tooltip: trans("chatControllerSignal.userIdTooltip"), + })} + {comp.children.userName.propertyView({ + label: trans("chatControllerSignal.userNameLabel"), + tooltip: trans("chatControllerSignal.userNameTooltip"), + })} +
+
+ {comp.children.onEvent.getPropertyView()} +
+ + ), +); + +// โ”€โ”€โ”€ Expose state properties โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +let ChatControllerSignal = withExposingConfigs(ChatControllerSignalWithProps, [ + new NameConfig("ready", trans("chatControllerSignal.readyExposed")), + new NameConfig("error", trans("chatControllerSignal.errorExposed")), + new NameConfig( + "connectionStatus", + trans("chatControllerSignal.connectionStatusExposed"), + ), + new NameConfig( + "onlineUsers", + trans("chatControllerSignal.onlineUsersExposed"), + ), + new NameConfig( + "typingUsers", + trans("chatControllerSignal.typingUsersExposed"), + ), + new NameConfig("currentRoomId", trans("chatControllerSignal.currentRoomIdExposed")), + new NameConfig("userId", trans("chatControllerSignal.userIdExposed")), + new NameConfig("userName", trans("chatControllerSignal.userNameExposed")), + new NameConfig("applicationId", trans("chatControllerSignal.applicationIdExposed")), + new NameConfig( + "aiThinkingRooms", + trans("chatControllerSignal.aiThinkingRoomsExposed"), + ), + new NameConfig( + "sharedState", + trans("chatControllerSignal.sharedStateExposed"), + ), + new NameConfig( + "roomData", + trans("chatControllerSignal.roomDataExposed"), + ), +]); + +// โ”€โ”€โ”€ Expose methods โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +ChatControllerSignal = withMethodExposing(ChatControllerSignal, [ + { + method: { + name: "startTyping", + description: trans("chatControllerSignal.startTypingMethodDesc"), + params: [{ name: "roomId", type: "string" }], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.startTyping) { + actions.startTyping(values?.[0] as string | undefined); + } + }, + }, + { + method: { + name: "stopTyping", + description: trans("chatControllerSignal.stopTypingMethodDesc"), + params: [], + }, + execute: (comp) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.stopTyping) { + actions.stopTyping(); + } + }, + }, + { + method: { + name: "switchRoom", + description: trans("chatControllerSignal.switchRoomMethodDesc"), + params: [{ name: "roomId", type: "string" }], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.switchRoom) { + actions.switchRoom(values?.[0] as string); + } + }, + }, + { + method: { + name: "setAiThinking", + description: trans("chatControllerSignal.setAiThinkingMethodDesc"), + 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: "setSharedState", + description: trans("chatControllerSignal.setSharedStateMethodDesc"), + 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: trans("chatControllerSignal.deleteSharedStateMethodDesc"), + 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: trans("chatControllerSignal.setRoomDataMethodDesc"), + 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: trans("chatControllerSignal.deleteRoomDataMethodDesc"), + 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", + description: trans("chatControllerSignal.setUserMethodDesc"), + 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 { ChatControllerSignal }; diff --git a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx index fa4294709b..30288420a7 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx @@ -37,7 +37,7 @@ import { ThemeComp } from "./themeComp"; import UrlParamsHookComp from "./UrlParamsHookComp"; import { UtilsComp } from "./utilsComp"; import { ScreenInfoHookComp } from "./screenInfoComp"; -import { ChatControllerComp } from "../comps/chatBoxComponent/chatControllerComp"; +import { ChatControllerSignal } from "./chatControllerV2Comp"; window._ = _; window.dayjs = dayjs; @@ -119,7 +119,7 @@ const HookMap: HookCompMapRawType = { urlParams: UrlParamsHookComp, drawer: DrawerComp, theme: ThemeComp, - chatController: ChatControllerComp, + chatControllerSignal: ChatControllerSignal, }; export const HookTmpComp = withTypeAndChildren(HookMap, "title", { @@ -157,8 +157,7 @@ function SelectHookView(props: { if ( (props.compType !== "modal" && props.compType !== "drawer" && - props.compType !== "meeting" && - props.compType !== "chatController") || + props.compType !== "meeting") || !selectedComp || (editorState.selectSource !== "addComp" && editorState.selectSource !== "leftPanel") diff --git a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx index 22e79e6d17..18619ed8a0 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx @@ -19,7 +19,7 @@ const AllHookComp = [ "urlParams", "theme", "meeting", - "chatController" + "chatControllerSignal" ] as const; export type HookCompType = (typeof AllHookComp)[number]; @@ -50,7 +50,7 @@ const HookCompConfig: Record< category: "ui", singleton: false, }, - chatController: { + chatControllerSignal: { category: "ui", singleton: false, }, diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index be34b16707..ae4b19bd4c 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -194,8 +194,8 @@ import { ModalComp } from "./hooks/modalComp"; import { defaultCollapsibleContainerData } from "./comps/containerComp/collapsibleContainerComp"; import { ContainerComp as FloatTextContainerComp } from "./comps/containerComp/textContainerComp"; import { ChatComp } from "./comps/chatComp"; -import { ChatBoxComp } from "./comps/chatBoxComponent"; -import { ChatControllerComp } from "./comps/chatBoxComponent/chatControllerComp"; +import { ChatControllerSignal } from "./hooks/chatControllerV2Comp"; +import { ChatBoxV2Comp } from "./comps/chatBoxComponentv2"; type Registry = { [key in UICompType]?: UICompManifest; @@ -948,29 +948,28 @@ export var uiCompMap: Registry = { comp: MentionComp, }, - chatBox: { - name: "Chat Box", - enName: "Chat Box", - description: "Advanced Chat Box Component with Rooms and People", + 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 Hocuspocus/Yjs. Pair with Chat Box V2 and your own data queries.", categories: ["collaboration"], icon: CommentCompIcon, - keywords: "chatbox,chat,conversation,rooms,messaging", - comp: ChatBoxComp, - layoutInfo: { - w: 12, - h: 24, - }, + keywords: "chatbox,chat,controller,signal,realtime,presence,typing,hocuspocus,yjs", + comp: ChatControllerSignal, }, - chatController: { - name: "Chat Controller", - enName: "Chat Controller", - description: "Advanced Chat Controller Component with Rooms and People", + chatBoxV: { + name: "Chat Box V2", + enName: "Chat Box V2", + description: "Chat UI component โ€” displays messages from any data source, fires send events, shows typing indicators from Chat Signal Controller", categories: ["collaboration"], icon: CommentCompIcon, - keywords: "chatbox,chat,conversation,rooms,messaging", - comp: ChatControllerComp, - isContainer: true, + keywords: "chatbox,chat,conversation,messaging,v2", + comp: ChatBoxV2Comp, + layoutInfo: { + w: 12, + h: 24, + }, }, // Forms diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index 1de611df8a..9842b50e8a 100644 --- a/client/packages/lowcoder/src/comps/uiCompRegistry.ts +++ b/client/packages/lowcoder/src/comps/uiCompRegistry.ts @@ -143,8 +143,8 @@ export type UICompType = | "comment" //Added By Mousheng | "mention" //Added By Mousheng | "chat" //Added By Kamal Qureshi - | "chatBox" //Added By Kamal Qureshi - | "chatController" + | "chatControllerSignal" + | "chatBoxV" | "autocomplete" //Added By Mousheng | "colorPicker" //Added By Mousheng | "floatingButton" //Added By Mousheng diff --git a/client/packages/lowcoder/src/dev-utils/buildVars.js b/client/packages/lowcoder/src/dev-utils/buildVars.js index 73c7c1b2b4..d0f74ba282 100644 --- a/client/packages/lowcoder/src/dev-utils/buildVars.js +++ b/client/packages/lowcoder/src/dev-utils/buildVars.js @@ -43,6 +43,14 @@ export const buildVars = [ name: "REACT_APP_SERVER_IPS", defaultValue: "", }, + { + name: "REACT_APP_HOCUSPOCUS_URL", + defaultValue: "", + }, + { + name: "REACT_APP_HOCUSPOCUS_SECRET", + defaultValue: "", + }, { name: "REACT_APP_BUNDLE_BUILTIN_PLUGIN", defaultValue: "", diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 56fa433e2f..66c51e6ae3 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -600,6 +600,59 @@ 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", + "threadItemBackground": "Thread Item Background", + "threadItemText": "Thread Item Text", + "threadItemBorder": "Thread Item Border", + "activeThreadBackground": "Active Thread Background", + "activeThreadText": "Active Thread Text", + "activeThreadBorder": "Active Thread Border", + + // ChatBox V2 Styles + "chatV2SidebarBackground": "Background", + "chatV2SidebarText": "Text", + "chatV2SidebarBorder": "Border", + "chatV2SidebarHeaderBackground": "Header BG", + "chatV2ActiveItemBackground": "Active BG", + "chatV2ActiveItemText": "Active Text", + "chatV2SidebarItemPadding": "Item Padding", + "chatV2SidebarRadius": "Radius", + "chatV2HeaderBackground": "Background", + "chatV2HeaderText": "Text", + "chatV2HeaderBorder": "Border", + "chatV2HeaderPadding": "Padding", + "chatV2MessageAreaBackground": "Area BG", + "chatV2OwnMessageBackground": "Own Msg BG", + "chatV2OwnMessageText": "Own Msg Text", + "chatV2OtherMessageBackground": "Other Msg BG", + "chatV2OtherMessageText": "Other Msg Text", + "chatV2MessageBubblePadding": "Bubble Padding", + "chatV2MessageBubbleRadius": "Bubble Radius", + "chatV2MessageMetaText": "Meta Text", + "chatV2InputBackground": "Background", + "chatV2InputText": "Text", + "chatV2InputBorder": "Border", + "chatV2InputRadius": "Radius", + "chatV2InputPadding": "Padding", + "chatV2InputAreaBackground": "Area BG", + "chatV2InputAreaBorder": "Area Border", + "chatV2SendButtonBackground": "Send Btn BG", + "chatV2SendButtonIcon": "Send Btn Icon", + "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.", @@ -1421,18 +1474,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)", @@ -1453,11 +1499,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", @@ -1477,37 +1518,215 @@ 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", - "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", + "threadItemStyle": "Thread Item Style" }, - "chatBox": { - // Event Labels & Descriptions - "connected": "Connected", - "connectedDesc": "Triggered when the chat connects to the server", - "disconnected": "Disconnected", - "disconnectedDesc": "Triggered when the chat disconnects from the server", - "messageReceived": "Message Received", - "messageReceivedDesc": "Triggered when a new message is received from another user", + "chatBoxV2": { "messageSent": "Message Sent", - "messageSentDesc": "Triggered when the current user sends a message", + "messageSentDesc": "Triggered when the user presses send. Read chatBox.lastSentMessageText to get the message content.", + "startTyping": "Start Typing", + "startTypingDesc": "Triggered when the user starts typing. Wire this to chatController.startTyping().", + "stopTyping": "Stop Typing", + "stopTypingDesc": "Triggered when the user stops typing. Wire this to chatController.stopTyping().", + "roomSwitch": "Room Switch", + "roomSwitchDesc": "User clicked a room they are already a member of. Read chatBox.pendingRoomId, then call chatController.switchRoom with that room ID.", + "roomJoin": "Room Join", + "roomJoinDesc": "User wants to join a room from search results. Read chatBox.pendingRoomId, then call chatController.joinRoom with that room ID.", + "roomLeave": "Room Leave", + "roomLeaveDesc": "User clicked leave on a room. Read chatBox.pendingRoomId, then call chatController.leaveRoom with that room ID.", + "roomCreate": "Room Create", + "roomCreateDesc": "User submitted the create-room form. Read chatBox.newRoomName, newRoomType, newRoomDescription, newRoomLlmQuery, then call chatController.createRoom(...).", + "inviteSend": "Invite Send", + "inviteSendDesc": "User sent a room invite. Read chatBox.inviteTargetUserId, then call chatController.sendInvite with the current room ID and that user ID.", + "inviteAccept": "Invite Accept", + "inviteAcceptDesc": "User accepted a pending invite. Read chatBox.pendingInviteId, then call chatController.acceptInvite with that invite ID.", + "inviteDecline": "Invite Decline", + "inviteDeclineDesc": "User declined a pending invite. Read chatBox.pendingInviteId, then call chatController.declineInvite with that invite ID.", + "chatTitleDefault": "Chat", + "currentUserNameDefault": "User", + "chatTitleLabel": "Chat Title", + "chatTitleTooltip": "Display title shown in the chat header", + "messagesLabel": "Messages", + "messagesTooltip": "Bind to your data query, such as loadMessages.data. Expected fields include id, text, authorId, authorName, and timestamp.", + "currentUserIdLabel": "Current User ID", + "currentUserIdTooltip": "The current user's ID, used to distinguish own vs. other messages. Bind it to chatController1.userId.", + "currentUserNameLabel": "Current User Name", + "currentUserNameTooltip": "The current user's display name", + "roomsPanelSection": "Rooms Panel", + "showRoomsPanelLabel": "Show Rooms Panel", + "panelWidthLabel": "Panel Width", + "panelWidthTooltip": "Width of the rooms sidebar, e.g. 240px or 30%", + "roomsLabel": "Rooms", + "roomsTooltip": "Bind to chatController1.userRooms, the list of rooms visible to the current user.", + "currentRoomIdLabel": "Current Room ID", + "currentRoomIdTooltip": "Bind to chatController1.currentRoomId to highlight the active room.", + "pendingInvitesLabel": "Pending Invites", + "pendingInvitesTooltip": "Bind to chatController1.pendingInvites to show invite notifications.", + "allowRoomCreationLabel": "Allow Room Creation", + "allowRoomSearchLabel": "Allow Room Search", + "realTimeSection": "Real-time", + "typingUsersLabel": "Typing Users", + "typingUsersTooltip": "Array of users currently typing. Bind to chatController1.typingUsers.", + "aiIsThinkingLabel": "AI Is Thinking", + "aiIsThinkingTooltip": "Show the AI thinking animation to all users in this room. Bind to the current room value from chatController1.aiThinkingRooms.", + "onlineUsersLabel": "Online Users", + "onlineUsersTooltip": "Array of online users with presence. Bind to chatController1.onlineUsers. Each item includes userId, userName, and currentRoomId.", + "displaySection": "Display", + "showHeaderLabel": "Show Header", + "sidebarStyleSection": "Sidebar Style", + "headerStyleSection": "Header Style", + "messageStyleSection": "Message Style", + "inputStyleSection": "Input Style", + "chatTitleExposed": "Chat display title", + "lastSentMessageTextExposed": "Text of the last message sent by the user โ€” use in your save query", + "messageTextExposed": "Current text in the message input", + "currentRoomIdExposed": "Currently active room ID โ€” for AI thinking or room-scoped queries", + "pendingRoomIdExposed": "Room ID the user wants to switch to, join, or leave โ€” read in roomSwitch/roomJoin/roomLeave events", + "newRoomNameExposed": "Name entered in the create-room form", + "newRoomTypeExposed": "Type selected in the create-room form: public | private | llm", + "newRoomDescriptionExposed": "Description entered in the create-room form", + "newRoomLlmQueryExposed": "Query name entered for LLM rooms in the create-room form", + "inviteTargetUserIdExposed": "User ID entered in the invite form โ€” read in inviteSend event", + "pendingInviteIdExposed": "Invite ID the user accepted or declined โ€” read in inviteAccept/inviteDecline events", + "onlineCount": "{count} online", + "typeMessagePlaceholder": "Type a message...", + "joinRoomTitle": "Join \"{roomName}\"", + "aiShortLabel": "AI", + "joinAction": "Join", + "leaveRoomConfirm": "Leave \"{roomName}\"?", + "leaveAction": "Leave", + "cancelAction": "Cancel", + "roomsHeader": "Rooms", + "inviteUserToRoomTooltip": "Invite user to room", + "createRoomTooltip": "Create room", + "searchPublicRoomsPlaceholder": "Search public rooms...", + "searchResultsCountSingle": "{count} result", + "searchResultsCountPlural": "{count} results", + "noPublicRoomsMatch": "No public rooms match \"{searchQuery}\"", + "backAction": "Back", + "pendingInvitesHeader": "Pending Invites ({count})", + "invitedBy": "Invited by {userName}", + "acceptAction": "Accept", + "declineAction": "Decline", + "noRoomsYet": "No rooms yet.", + "noRoomsYetCreateOne": "No rooms yet. Create one!", + "aiRoomsLabel": "AI Rooms", + "publicRoomsLabel": "Public", + "privateRoomsLabel": "Private", + "onlinePresence": "Online โ€” {count}", + "userWithYou": "{userName} (You)", + "createRoomModalTitle": "Create Room", + "roomTypeLabel": "ROOM TYPE", + "normalRoomLabel": "Normal Room", + "aiRoomLabel": "AI / LLM Room", + "aiRoomStrongLabel": "AI Room", + "aiRoomMessage": "โ€” every user message triggers your Lowcoder query. The AI response is broadcast to all members in real time.", + "roomNameLabel": "Room Name", + "roomNameRequired": "Room name is required", + "roomNameMin": "At least 2 characters", + "roomNameMax": "At most 50 characters", + "roomNamePlaceholderAi": "e.g. GPT-4 Assistant", + "roomNamePlaceholderNormal": "e.g. Design Team", + "descriptionLabel": "Description", + "descriptionPlaceholder": "What is this room about?", + "visibilityLabel": "Visibility", + "queryNameLabel": "Query Name", + "queryNameHint": "name of your Lowcoder query", + "queryNameRequired": "A query name is required for AI rooms", + "queryNameExtraPrefix": "Create a query in the bottom panel of Lowcoder and enter its exact name here. Your query will receive", + "queryNameExtraSuffix": "as arguments.", + "queryNamePlaceholder": "e.g. getAIResponse", + "createAiRoomButton": "Create AI Room", + "createRoomButton": "Create Room", + "inviteUserTitle": "Invite User", + "invitesPrivateOnly": "Invites are available only in private rooms.", + "sendingInviteFor": "Sending invite for {roomName}", + "inviteUserIdLabel": "User ID", + "inviteUserIdRequired": "User ID is required", + "inviteUserIdPlaceholder": "e.g. user_42", + "inviteUserNameOptionalLabel": "User Name (optional)", + "inviteUserNamePlaceholder": "e.g. Sarah", + "sendInviteButton": "Send Invite", + "copied": "Copied!", + "copyAction": "Copy", + "copyAiResponse": "Copy AI response", + "noMessagesYet": "No messages yet", + "startConversation": "Start the conversation!", + "aiThinking": "AI is thinking...", + "singleUserTyping": "{userName} is typing...", + "multipleUsersTyping": "{count} people are typing...", + "someoneLabel": "Someone" + }, + "chatControllerSignal": { "userJoined": "User Joined", - "userJoinedDesc": "Triggered when a new user joins the current room", + "userJoinedDesc": "A user came online in this application", "userLeft": "User Left", - "userLeftDesc": "Triggered when a user leaves the current room", - "typingStarted": "Typing Started", - "typingStartedDesc": "Triggered when someone starts typing in the current room", - "typingStopped": "Typing Stopped", - "typingStoppedDesc": "Triggered when someone stops typing in the current room", - "roomJoined": "Room Joined", - "roomJoinedDesc": "Triggered when the current user joins a room", - "roomLeft": "Room Left", - "roomLeftDesc": "Triggered when the current user leaves a room", + "userLeftDesc": "A user went offline", + "roomSwitched": "Room Switched", + "roomSwitchedDesc": "Active room changed. Read currentRoomId.", + "connected": "Connected", + "connectedDesc": "Connected to the signal server", + "disconnected": "Disconnected", + "disconnectedDesc": "Disconnected from the signal server", "error": "Error", - "errorDesc": "Triggered when an error occurs in the chat system" + "errorDesc": "A connection error occurred", + "aiThinkingStarted": "AI Thinking Started", + "aiThinkingStartedDesc": "The AI assistant started generating a response in a room", + "aiThinkingStopped": "AI Thinking Stopped", + "aiThinkingStoppedDesc": "The AI assistant finished (or was cancelled) in a room", + "sharedStateChanged": "Shared State Changed", + "sharedStateChangedDesc": "The app-level shared state was updated by any user. Read chatController.sharedState to get the current state.", + "roomDataChanged": "Room Data Changed", + "roomDataChangedDesc": "Room-scoped shared data was updated by any user. Read chatController.roomData to get the current data.", + "userNameDefault": "User", + "onlineStatus": "Online", + "connectingStatus": "Connecting...", + "offlineStatus": "Offline", + "authenticationFailed": "Authentication failed", + "applicationIdLabel": "Application ID", + "applicationIdTooltip": "Scopes the signal room to this application. All users of the same app share presence and notifications.", + "userIdLabel": "User ID", + "userIdTooltip": "Current user's unique identifier", + "userNameLabel": "User Name", + "userNameTooltip": "Current user's display name", + "readyExposed": "Whether the signal server is connected and ready", + "errorExposed": "Error message if connection failed", + "connectionStatusExposed": "Current connection status (Online / Connecting... / Offline)", + "onlineUsersExposed": "Array of currently online users, including userId, userName, and currentRoomId.", + "typingUsersExposed": "Array of users currently typing, including userId, userName, and roomId.", + "currentRoomIdExposed": "Currently active room/channel ID", + "userIdExposed": "Current user ID", + "userNameExposed": "Current user name", + "applicationIdExposed": "Application scope ID", + "aiThinkingRoomsExposed": "Map of room IDs to boolean values showing which rooms currently have an AI thinking.", + "sharedStateExposed": "App-level shared state in JSON form that auto-syncs across all connected users. Write to it with setSharedState.", + "roomDataExposed": "Room-scoped shared data in JSON form that auto-syncs. It is not visible as chat messages. Write to it with setRoomData.", + "startTypingMethodDesc": "Signal that the current user started typing. Other users will see the typing indicator.", + "stopTypingMethodDesc": "Signal that the current user stopped typing", + "switchRoomMethodDesc": "Set the current room/channel context. Presence and typing will scope to this room.", + "setAiThinkingMethodDesc": "Broadcast to all room members that the AI assistant is thinking (or has finished). All users in the room will see the thinking indicator.", + "setSharedStateMethodDesc": "Set a key-value pair in the app-level shared state. Auto-syncs to all connected users instantly via CRDT.", + "deleteSharedStateMethodDesc": "Delete a key from the app-level shared state.", + "setRoomDataMethodDesc": "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.", + "deleteRoomDataMethodDesc": "Delete a key from a room's shared data. If no key is provided, deletes all data for the room.", + "setUserMethodDesc": "Update the current user credentials" }, + // eighth part "comp": { diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index a558d8b8d6..3820ecf1e5 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -307,6 +307,6 @@ export const CompStateIcon: { themeriverChart: , basicChart: , chat: , - chatBox: , - chatController: , + chatControllerSignal: , + chatBoxV: , } as const; diff --git a/client/packages/lowcoder/src/pages/setting/theme/ThemeCompPanel.tsx b/client/packages/lowcoder/src/pages/setting/theme/ThemeCompPanel.tsx index 22af429cad..bf70fd1d2b 100644 --- a/client/packages/lowcoder/src/pages/setting/theme/ThemeCompPanel.tsx +++ b/client/packages/lowcoder/src/pages/setting/theme/ThemeCompPanel.tsx @@ -205,13 +205,17 @@ export const ThemeCompPanel = (props: any) => { if (newComp) { const compChildrens = newComp.children; - let styleChildrenKeys = Object.keys(compChildrens).filter(child => child.toLowerCase().endsWith('style' || 'styles')); + const isStyleChild = (child: string) => { + const normalizedChild = child.toLowerCase(); + return normalizedChild.endsWith("style") || normalizedChild.endsWith("styles"); + }; + let styleChildrenKeys = Object.keys(compChildrens).filter(isStyleChild); let styleChildrens: Record = {}; styleChildrenKeys.forEach((childKey: string) => { styleChildrens[childKey] = compChildrens[childKey]; }) if (compChildrens.container) { - styleChildrenKeys = Object.keys(compChildrens.container.children).filter(child => child.toLowerCase().endsWith('style' || 'styles')); + styleChildrenKeys = Object.keys(compChildrens.container.children).filter(isStyleChild); styleChildrenKeys.forEach((childKey: string) => { styleChildrens[childKey] = compChildrens.container.children[childKey]; }) diff --git a/client/packages/lowcoder/src/util/dateTimeUtils.ts b/client/packages/lowcoder/src/util/dateTimeUtils.ts index fba3affdc7..97f362118c 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"); +} diff --git a/client/yarn.lock b/client/yarn.lock index 7f48f58785..2b5e4e2b6c 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2715,6 +2715,47 @@ __metadata: languageName: node linkType: hard +"@hocuspocus/common@npm:^3.4.4": + version: 3.4.4 + resolution: "@hocuspocus/common@npm:3.4.4" + dependencies: + lib0: ^0.2.87 + checksum: 7282aa7ffb445902115ce8126e0f7b268c902e7671ee12537d01ab9359b07b58148b6ccb1e48c86b4054b03104246c2870793e9e8cbb388040b91493eb278c15 + languageName: node + linkType: hard + +"@hocuspocus/provider@npm:^3.4.4": + version: 3.4.4 + resolution: "@hocuspocus/provider@npm:3.4.4" + dependencies: + "@hocuspocus/common": ^3.4.4 + "@lifeomic/attempt": ^3.0.2 + lib0: ^0.2.87 + ws: ^8.17.1 + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + checksum: 76bc4e8da0a1bc5f405b6c438563b1ef92c19f6a6f9a785f531fe9925c56f4be80c37e59e41aad523a17892d84080e10cce8ab75b46ca40380ac9d6bf070f314 + languageName: node + linkType: hard + +"@hocuspocus/server@npm:^3.4.4": + version: 3.4.4 + resolution: "@hocuspocus/server@npm:3.4.4" + dependencies: + "@hocuspocus/common": ^3.4.4 + async-lock: ^1.3.1 + async-mutex: ^0.5.0 + kleur: ^4.1.4 + lib0: ^0.2.47 + ws: ^8.5.0 + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + checksum: c13ca192a1a8b16832c8370ca39b0b6c3ae216a2f039553b2dc715e8aa5eec35cbbf3d762f3bd4dc5b6277f356cc7d88d19be0f279f22239d8df94626d43241b + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.13.0": version: 0.13.0 resolution: "@humanwhocodes/config-array@npm:0.13.0" @@ -3195,6 +3236,13 @@ __metadata: languageName: node linkType: hard +"@lifeomic/attempt@npm:^3.0.2": + version: 3.1.0 + resolution: "@lifeomic/attempt@npm:3.1.0" + checksum: 567d98e7a8c348790aa77d26524a9f6e4ddc51673d4b586025053d57ea50f84c8848cb032bebcbb85d78f245f9ecfbac67ac7febc3bc5f0371101288a035793a + languageName: node + linkType: hard + "@lottiefiles/dotlottie-react@npm:^0.13.0": version: 0.13.5 resolution: "@lottiefiles/dotlottie-react@npm:0.13.5" @@ -6430,6 +6478,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" @@ -7155,6 +7213,22 @@ __metadata: languageName: node linkType: hard +"async-lock@npm:^1.3.1": + version: 1.4.1 + resolution: "async-lock@npm:1.4.1" + checksum: 29e70cd892932b7c202437786cedc39ff62123cb6941014739bd3cabd6106326416e9e7c21285a5d1dc042cad239a0f7ec9c44658491ee4a615fd36a21c1d10a + languageName: node + linkType: hard + +"async-mutex@npm:^0.5.0": + version: 0.5.0 + resolution: "async-mutex@npm:0.5.0" + dependencies: + tslib: ^2.4.0 + checksum: be1587f4875f3bb15e34e9fcce82eac2966daef4432c8d0046e61947fb9a1b95405284601bc7ce4869319249bc07c75100880191db6af11d1498931ac2a2f9ea + languageName: node + linkType: hard + "async-validator@npm:^4.1.0": version: 4.2.5 resolution: "async-validator@npm:4.2.5" @@ -7585,6 +7659,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 +7917,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 +8553,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 +8581,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 +8595,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 +8676,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 +9604,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 +9779,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 +10239,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 +10253,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 +10615,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 +11071,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 +11210,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 +11499,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 +11695,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" @@ -12338,6 +12529,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 +12704,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 +13284,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" @@ -14277,7 +14497,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.4": version: 4.1.5 resolution: "kleur@npm:4.1.5" checksum: 1dc476e32741acf0b1b5b0627ffd0d722e342c1b0da14de3e8ae97821327ca08f9fb944542fb3c126d90ac5f27f9d804edbe7c585bf7d12ef495d115e0f22c12 @@ -14614,6 +14834,19 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"lib0@npm:^0.2.47, lib0@npm:^0.2.74, lib0@npm:^0.2.87": + 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" @@ -15043,7 +15276,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: svgo: ^3.0.0 ts-node: ^10.4.0 tui-image-editor: ^3.15.3 - typescript: ^4.8.4 + typescript: ^5.6.2 whatwg-fetch: ^3.6.2 languageName: unknown linkType: soft @@ -15151,6 +15384,8 @@ coolshapes-react@lowcoder-org/coolshapes-react: "@fortawesome/free-regular-svg-icons": ^6.5.1 "@fortawesome/free-solid-svg-icons": ^6.5.1 "@fortawesome/react-fontawesome": latest + "@hocuspocus/provider": ^3.4.4 + "@hocuspocus/server": ^3.4.4 "@jsonforms/core": ^3.5.1 "@lottiefiles/dotlottie-react": ^0.13.0 "@manaflair/redux-batch": ^1.0.0 @@ -15189,6 +15424,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 +15434,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 @@ -15256,7 +15493,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: stylis: ^4.1.1 supercluster: ^8.0.1 tern: ^0.24.3 - typescript: ^4.8.4 + typescript: ^5.6.2 typescript-collections: ^1.3.3 ua-parser-js: ^1.0.33 uuid: ^9.0.0 @@ -15270,9 +15507,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: ^3.25.76 languageName: unknown linkType: soft @@ -15742,6 +15981,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 +16024,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 +16684,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 +16700,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 +17286,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 +17426,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 +17735,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 +17827,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 +18186,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 +18295,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 +18393,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 +20534,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 +20832,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 +20896,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 +20966,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 +21463,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 +22152,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 @@ -22073,7 +22414,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.1, tslib@npm:^2.5.0, tslib@npm:^2.8.0": +"tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.4.1, tslib@npm:^2.5.0, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: e4aba30e632b8c8902b47587fd13345e2827fa639e7c3121074d5ee0880723282411a8838f830b55100cbe4517672f84a2472667d355b81e8af165a55dc6203a @@ -22192,6 +22533,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" @@ -22331,6 +22683,16 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"typescript@npm:^5.6.2": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 0d0ffb84f2cd072c3e164c79a2e5a1a1f4f168e84cb2882ff8967b92afe1def6c2a91f6838fb58b168428f9458c57a2ba06a6737711fdd87a256bbe83e9a217f + languageName: node + linkType: hard + "typescript@patch:typescript@4.8.4#~builtin": version: 4.8.4 resolution: "typescript@patch:typescript@npm%3A4.8.4#~builtin::version=4.8.4&hash=1a91c8" @@ -22361,6 +22723,16 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"typescript@patch:typescript@^5.6.2#~builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#~builtin::version=5.9.3&hash=29ae49" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 8bb8d86819ac86a498eada254cad7fb69c5f74778506c700c2a712daeaff21d3a6f51fd0d534fe16903cb010d1b74f89437a3d02d4d0ff5ca2ba9a4660de8497 + languageName: node + linkType: hard + "ua-parser-js@npm:^0.7.34": version: 0.7.40 resolution: "ua-parser-js@npm:0.7.40" @@ -22881,7 +23253,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 @@ -23779,6 +24151,21 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"ws@npm:^8.17.1, ws@npm:^8.5.0": + version: 8.19.0 + resolution: "ws@npm:8.19.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 7a426122c373e053a65a2affbcdcdbf8f643ba0265577afd4e08595397ca244c05de81570300711e2363a9dab5aea3ae644b445bc7468b1ebbb51bfe2efb20e1 + languageName: node + linkType: hard + "ws@npm:^8.18.3": version: 8.18.3 resolution: "ws@npm:8.18.3" @@ -23872,6 +24259,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 +24437,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"zod@npm:^3.25.76": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: c9a403a62b329188a5f6bd24d5d935d2bba345f7ab8151d1baa1505b5da9f227fb139354b043711490c798e91f3df75991395e40142e6510a4b16409f302b849 + languageName: node + linkType: hard + "zrender@npm:5.6.1, zrender@npm:^5.1.1": version: 5.6.1 resolution: "zrender@npm:5.6.1" diff --git a/package.json b/package.json index bc53d2bff6..bd286774f2 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,6 @@ "typescript": "^5.6.2" }, "dependencies": { - "@pluv/client": "^3.2.2", - "@pluv/react": "^3.2.2", "axios": "^1.7.7", "fs": "^0.0.1-security", "path": "^0.12.7",