diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index 9aac99d87..7c9fdaa60 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -33,6 +33,11 @@ "@jsonforms/core": "^3.5.1", "@lottiefiles/dotlottie-react": "^0.13.0", "@manaflair/redux-batch": "^1.0.0", + "@pluv/client": "^4.0.1", + "@pluv/crdt-yjs": "^4.0.1", + "@pluv/io": "^4.0.1", + "@pluv/platform-pluv": "^4.0.1", + "@pluv/react": "^4.0.1", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-slot": "^1.2.3", @@ -59,11 +64,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,13 +132,16 @@ "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'", "start": "REACT_APP_LOG_LEVEL=debug REACT_APP_ENV=local vite", + "start:pluv": "node pluv-server.js", "build": "vite build && cp ../../VERSION ./build/VERSION", "preview": "vite preview", "prepare": "husky install" diff --git a/client/packages/lowcoder/pluv-server.js b/client/packages/lowcoder/pluv-server.js new file mode 100644 index 000000000..ff70d0f0f --- /dev/null +++ b/client/packages/lowcoder/pluv-server.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +/** + * Pluv.io Auth & Webhook Server for ChatBox v2 + * + * Replaces yjs-websocket-server.js — pluv.io manages all WebSocket + * infrastructure, so this server only handles: + * 1. Token creation (auth endpoint) + * 2. Webhook ingestion (server-side events like storage persistence) + * + * Required env vars: + * PLUV_PUBLISHABLE_KEY – your pluv.io public/publishable key + * PLUV_SECRET_KEY – your pluv.io secret key + * PLUV_WEBHOOK_SECRET – (optional) webhook signing secret + * + * Usage: node pluv-server.js + */ + +import { createIO } from "@pluv/io"; +import { platformPluv } from "@pluv/platform-pluv"; +import { yjs } from "@pluv/crdt-yjs"; +import { z } from "zod"; +import express from "express"; +import cors from "cors"; + +const PORT = process.env.PORT || 3006; +const HOST = process.env.HOST || "0.0.0.0"; + +const PLUV_PUBLISHABLE_KEY = process.env.PLUV_PUBLISHABLE_KEY; +const PLUV_SECRET_KEY = process.env.PLUV_SECRET_KEY; +const PLUV_WEBHOOK_SECRET = process.env.PLUV_WEBHOOK_SECRET; + +if (!PLUV_PUBLISHABLE_KEY || !PLUV_SECRET_KEY) { + console.error( + "Missing required env vars: PLUV_PUBLISHABLE_KEY, PLUV_SECRET_KEY", + ); + process.exit(1); +} + +// ── Pluv IO setup ───────────────────────────────────────────────────────── + +const io = createIO( + platformPluv({ + authorize: { + user: z.object({ + id: z.string(), + name: z.string(), + }), + }, + crdt: yjs, + publicKey: PLUV_PUBLISHABLE_KEY, + secretKey: PLUV_SECRET_KEY, + basePath: "/api/pluv", + ...(PLUV_WEBHOOK_SECRET ? { webhookSecret: PLUV_WEBHOOK_SECRET } : {}), + }), +); + +const ioServer = io.server({ + getInitialStorage: async ({ room }) => { + // No persistence yet — rooms start with empty storage. + // To add persistence, load encodedState from a database here. + console.log(`[pluv] getInitialStorage for room: ${room}`); + return null; + }, + + onRoomDestroyed: async ({ room }) => { + console.log(`[pluv] Room destroyed: ${room}`); + }, + + onStorageDestroyed: async ({ room, encodedState }) => { + // To persist storage, save encodedState to a database here. + console.log( + `[pluv] Storage destroyed for room: ${room} (${encodedState ? encodedState.length + " bytes" : "empty"})`, + ); + }, +}); + +// ── Express app ─────────────────────────────────────────────────────────── + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// Health check +app.get("/health", (_req, res) => { + res.json({ + status: "healthy", + server: "pluv-chat", + timestamp: new Date().toISOString(), + }); +}); + +// Webhook endpoint — pluv.io sends server events here +app.post("/api/pluv/webhook", async (req, res) => { + try { + // Convert express req/res to a standard Request for ioServer.fetch + const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`; + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") headers.set(key, value); + } + + const fetchReq = new Request(url, { + method: req.method, + headers, + body: req.method !== "GET" ? JSON.stringify(req.body) : undefined, + }); + + const fetchRes = await ioServer.fetch(fetchReq); + const body = await fetchRes.text(); + res.status(fetchRes.status).send(body); + } catch (err) { + console.error("[pluv] Webhook error:", err); + res.status(500).json({ error: "Webhook handling failed" }); + } +}); + +// Auth endpoint — creates a JWT token for the requesting user (must be after webhook route) +app.get("/api/auth/pluv", async (req, res) => { + try { + const room = req.query.room; + const userId = req.query.userId; + const userName = req.query.userName; + + if (!room || !userId) { + return res.status(400).json({ error: "Missing room or userId" }); + } + + const token = await ioServer.createToken({ + room: String(room), + user: { + id: String(userId), + name: String(userName || userId), + }, + }); + + // pluv expects the token as a plain text response + res.status(200).send(token); + } catch (err) { + console.error("[pluv] Auth error:", err); + res.status(500).json({ error: "Token creation failed" }); + } +}); + +// ── Start server ────────────────────────────────────────────────────────── + +app.listen(PORT, HOST, () => { + console.log(`\n Pluv Chat Server running on http://${HOST}:${PORT}`); + console.log(` Auth endpoint: GET /api/auth/pluv?room=...&userId=...`); + console.log(` Webhook endpoint: POST /api/pluv/webhook`); + console.log(` Health check: GET /health\n`); +}); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx new file mode 100644 index 000000000..84c7e58ec --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/ChatBoxContext.tsx @@ -0,0 +1,74 @@ +import { createContext, useContext } from "react"; +import type { ChatRoom, OnlineUser, PendingRoomInvite } from "./store"; + +type ChatEventName = + | "messageSent" + | "startTyping" + | "stopTyping" + | "roomSwitch" + | "roomJoin" + | "roomLeave" + | "roomCreate" + | "inviteSend" + | "inviteAccept" + | "inviteDecline"; + +interface ExposedState { + value: string; + onChange: (v: string) => void; +} + +export interface ChatBoxContextValue { + // Data + messages: any[]; + rooms: ChatRoom[]; + currentRoomId: string; + currentRoom: ChatRoom | null; + currentUserId: string; + currentUserName: string; + typingUsers: any[]; + 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: any; + animationStyle: any; + + // Events + onEvent: (event: ChatEventName) => any; + + // Room actions + onRoomSwitch: (roomId: string) => void; + onRoomJoin: (roomId: string) => void; + onRoomLeave: (roomId: string) => void; + onRoomCreate: ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ) => void; + onInviteSend: (toUserId: string) => void; + onInviteAccept: (inviteId: string) => void; + onInviteDecline: (inviteId: string) => void; +} + +export const ChatBoxContext = createContext(null); + +export function useChatBox(): ChatBoxContextValue { + const ctx = useContext(ChatBoxContext); + if (!ctx) { + throw new Error("useChatBox must be used within a ChatBoxProvider"); + } + return ctx; +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/README.md new file mode 100644 index 000000000..641fdd7e1 --- /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 000000000..871b45368 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/READMEv2.md @@ -0,0 +1,622 @@ +# ChatBox V2 + ChatController — Complete Guide + +## Architecture Overview + +The chat system uses two components and a server: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser (each user) │ +│ │ +│ ┌──────────────────┐ ┌───────────────────────────┐ │ +│ │ ChatController │◄─────►│ Pluv.io (WebSocket/YJS) │ │ +│ │ (hook component) │ │ CRDT auto-sync layer │ │ +│ │ │ └───────────────────────────┘ │ +│ │ Exposes: │ │ +│ │ • sharedState │ ┌───────────────────────────┐ │ +│ │ • roomData │ │ ChatBox │ │ +│ │ • onlineUsers │──────►│ (UI component) │ │ +│ │ • typingUsers │ │ Displays messages, rooms │ │ +│ │ • methods │ └───────────────────────────┘ │ +│ └──────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ MongoDB Queries │ ← You create these (save/load) │ +│ │ (data source) │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────┐ ┌──────────────────────┐ +│ pluv-server.js │ │ MongoDB Atlas │ +│ (auth + webhooks) │ │ (your database) │ +│ Port 3006 │ │ │ +└──────────────────────┘ └──────────────────────┘ +``` + +**ChatController** is a non-visual hook component. It manages: +- Real-time shared state (YJS CRDT via Pluv.io) — auto-syncs JSON across all users +- Presence — who's online, who's typing, what room they're in +- Room-scoped data — invisible JSON data per room/channel + +**ChatBox** is the visual chat UI. It receives data via property bindings and fires events on user interactions. It does NOT connect to Pluv directly — it's a pure display component. + +**pluv-server.js** handles auth token creation for Pluv.io. All WebSocket traffic goes through Pluv's cloud infrastructure, not this server. + +--- + +## Prerequisites + +### 1. Pluv.io Account + +Sign up at [pluv.io](https://pluv.io) and create a project. You need: +- `PLUV_PUBLISHABLE_KEY` (public, goes to the client) +- `PLUV_SECRET_KEY` (private, stays on the server) + +### 2. MongoDB + +A MongoDB database with two collections: +- `rooms` — stores chat room definitions +- `messages` — stores chat messages + +You can use MongoDB Atlas (free tier works) or a local instance. + +### 3. Environment Variables + +**pluv-server.js** (server-side): +``` +PLUV_PUBLISHABLE_KEY=pk_... +PLUV_SECRET_KEY=sk_... +PORT=3006 # optional, defaults to 3006 +``` + +**Lowcoder client** (build-time or runtime): +``` +VITE_PLUV_PUBLIC_KEY=pk_... +VITE_PLUV_AUTH_URL=/api/auth/pluv # optional, defaults to this +``` + +### 4. Start the Pluv Server + +```bash +cd client/packages/lowcoder +node pluv-server.js +``` + +Verify it's running: +```bash +curl http://localhost:3006/health +``` + +--- + +## MongoDB Setup + +### Create the Collections + +In your MongoDB database, create two collections. No special indexes are required for basic use, but recommended indexes are shown below. + +### `rooms` Collection + +Each document represents a chat room: + +```json +{ + "_id": "room_general", + "id": "room_general", + "name": "General", + "type": "public", + "description": "Main chat room for everyone", + "members": ["user_alice", "user_bob"], + "createdBy": "user_alice", + "createdAt": 1710600000000, + "llmQueryName": null +} +``` + +Insert a seed room so you have something to start with: + +```javascript +db.rooms.insertOne({ + id: "room_general", + name: "General", + type: "public", + description: "Main chat room", + members: [], + createdBy: "system", + createdAt: Date.now(), + llmQueryName: null +}) +``` + +### `messages` Collection + +Each document represents a single message: + +```json +{ + "_id": "msg_abc123", + "id": "msg_abc123", + "roomId": "room_general", + "text": "Hello everyone!", + "authorId": "user_alice", + "authorName": "Alice", + "timestamp": 1710600005000 +} +``` + +Recommended index for fast message loading: + +```javascript +db.messages.createIndex({ roomId: 1, timestamp: 1 }) +``` + +--- + +## Step-by-Step Setup in Lowcoder + +### Step 1: Add a MongoDB Data Source + +Go to **Settings → Data Sources → New Data Source → MongoDB**. Configure your connection string. + +### Step 2: Create the Queries + +You need 4 queries. Create them in the query panel of your app. + +#### Query: `loadRooms` + +Loads all rooms from MongoDB. + +- **Type**: MongoDB +- **Action**: Find +- **Collection**: `rooms` +- **Query**: `{}` + +This returns an array like `[{ id, name, type, members, ... }, ...]`. + +#### Query: `loadMessages` + +Loads messages for the current room. + +- **Type**: MongoDB +- **Action**: Find +- **Collection**: `messages` +- **Query**: `{ "roomId": "{{chatController1.currentRoomId}}" }` +- **Sort**: `{ "timestamp": 1 }` + +This returns messages sorted oldest-first for the active room. + +#### Query: `saveMessage` + +Inserts a new message into MongoDB. + +- **Type**: MongoDB +- **Action**: Insert One +- **Collection**: `messages` +- **Document**: + +```json +{ + "id": "msg_{{Date.now()}}_{{Math.random().toString(36).slice(2,9)}}", + "roomId": "{{chatController1.currentRoomId}}", + "text": "{{chatBox1.lastSentMessageText}}", + "authorId": "{{chatController1.userId}}", + "authorName": "{{chatController1.userName}}", + "timestamp": {{Date.now()}} +} +``` + +#### Query: `createRoom` + +Inserts a new room into MongoDB. + +- **Type**: MongoDB +- **Action**: Insert One +- **Collection**: `rooms` +- **Document**: + +```json +{ + "id": "room_{{Date.now()}}_{{Math.random().toString(36).slice(2,9)}}", + "name": "{{chatBox1.newRoomName}}", + "type": "{{chatBox1.newRoomType}}", + "description": "{{chatBox1.newRoomDescription}}", + "members": ["{{chatController1.userId}}"], + "createdBy": "{{chatController1.userId}}", + "createdAt": {{Date.now()}}, + "llmQueryName": null +} +``` + +### Step 3: Add the Components + +Drag these onto your canvas from the Insert panel: + +1. **ChatController** (found under Hooks in the insert panel — it's non-visual) +2. **ChatBox V2** (found under Components) + +### Step 4: Configure ChatController + +Select the ChatController in the component tree and set these properties: + +| Property | Value | +|---|---| +| Application ID | `{{currentUser.applicationId}}` or any fixed string like `"my_chat_app"` | +| User ID | `{{currentUser.id}}` or `{{currentUser.email}}` | +| User Name | `{{currentUser.name}}` | + +### Step 5: Configure ChatBox + +Select the ChatBox and set these property bindings: + +**Basic section:** + +| Property | Binding | +|---|---| +| Messages | `{{loadMessages.data}}` | +| Current User ID | `{{chatController1.userId}}` | +| Current User Name | `{{chatController1.userName}}` | + +**Rooms Panel section:** + +| Property | Binding | +|---|---| +| Rooms | `{{chatController1.sharedState.rooms \|\| []}}` | +| Current Room ID | `{{chatController1.currentRoomId}}` | +| Online Users | `{{chatController1.onlineUsers}}` | + +**Real-time section:** + +| Property | Binding | +|---|---| +| Typing Users | `{{chatController1.typingUsers}}` | +| AI Is Thinking | `{{chatController1.aiThinkingRooms[chatController1.currentRoomId]}}` | + +### Step 6: Wire the Events + +This is where the magic happens. Select the ChatBox and add event handlers: + +#### ChatBox Events + +| Event | Action | +|---|---| +| **Message Sent** | Run query `saveMessage` | +| **Message Sent** (2nd handler) | Run query `saveMessage` → on success chain: `chatController1.setRoomData(chatController1.currentRoomId, "lastMessage", { text: chatBox1.lastSentMessageText, authorId: chatController1.userId, ts: Date.now() })` | +| **Start Typing** | `chatController1.startTyping()` | +| **Stop Typing** | `chatController1.stopTyping()` | +| **Room Switch** | `chatController1.switchRoom(chatBox1.pendingRoomId)` | +| **Room Create** | Run query `createRoom` → on success chain: run `loadRooms` → on success: `chatController1.setSharedState("rooms", loadRooms.data)` | + +#### ChatController Events + +| Event | Action | +|---|---| +| **Connected** | Run query `loadRooms` → on success: `chatController1.setSharedState("rooms", loadRooms.data)` | +| **Room Switched** | Run query `loadMessages` | +| **Room Data Changed** | Run query `loadMessages` | + +--- + +## Complete Flow: How It All Works + +### Flow 1: App Opens — Loading Rooms + +``` +1. User opens the app +2. ChatController connects to Pluv.io → "Connected" event fires +3. Connected event handler runs loadRooms query +4. loadRooms returns rooms from MongoDB +5. Handler calls: chatController1.setSharedState("rooms", loadRooms.data) +6. Rooms are now in the YJS shared state + + Meanwhile, for other users already connected: + → YJS auto-syncs the shared state + → Their chatController1.sharedState.rooms updates instantly + → ChatBox re-renders with the room list + → They did NOT run any query — they got the data via YJS +``` + +### Flow 2: User Switches to a Room + +``` +1. User clicks "General" room in the sidebar +2. ChatBox fires "Room Switch" event with pendingRoomId = "room_general" +3. Event handler calls: chatController1.switchRoom("room_general") +4. ChatController updates currentRoomId and presence +5. "Room Switched" event fires +6. Event handler runs loadMessages query (filtered by currentRoomId) +7. loadMessages returns messages from MongoDB +8. ChatBox displays them (bound to {{ loadMessages.data }}) +``` + +### Flow 3: User Sends a Message — Other Users See It + +This is the key flow. Here's what happens step by step: + +``` +USER A (sender): + +1. Alice types "Hello!" and presses Send +2. ChatBox fires "Message Sent" event +3. Event handler runs saveMessage query + → Inserts { id, roomId, text: "Hello!", authorId: "alice", ... } into MongoDB +4. On saveMessage success, handler calls: + chatController1.setRoomData("room_general", "lastMessage", { + text: "Hello!", + authorId: "alice", + ts: 1710600005000 + }) +5. This writes a tiny JSON object to the YJS shared doc under roomData + +USER B (receiver): + +6. YJS auto-syncs the roomData change to Bob's browser +7. chatController1.roomData updates → "Room Data Changed" event fires +8. Event handler runs loadMessages query +9. loadMessages fetches the latest messages from MongoDB (including Alice's new message) +10. ChatBox re-renders with the new message visible + +Total time: ~100-300ms (YJS sync) + ~200-500ms (MongoDB query) +``` + +**What's happening under the hood:** +- Alice does NOT call any "broadcast" method. She just writes a tiny JSON to `roomData`. +- YJS (CRDT) syncs that JSON to all connected users automatically. +- Bob's browser reacts to the roomData change by reloading messages from MongoDB. +- The actual message lives in MongoDB (persistent, queryable). YJS only carries the "something changed" signal as a side effect of the data write. + +### Flow 4: Creating a Room — Other Users See It + +``` +USER A: + +1. Alice clicks "Create Room" → fills in name "Design Team" → submits +2. ChatBox fires "Room Create" event +3. Event handler runs createRoom query (inserts into MongoDB) +4. On success, runs loadRooms query (fetches all rooms) +5. On success, calls: chatController1.setSharedState("rooms", loadRooms.data) + +USER B: + +6. YJS auto-syncs sharedState.rooms to Bob's browser +7. chatController1.sharedState.rooms updates +8. ChatBox re-renders — "Design Team" room appears in the sidebar +9. Bob did NOT run any query — the room list came through YJS +``` + +### Flow 5: Typing Indicators + +``` +1. Alice starts typing in the message input +2. ChatBox fires "Start Typing" event +3. Event handler calls chatController1.startTyping() +4. Pluv presence updates: { userId: "alice", typing: true, currentRoomId: "room_general" } +5. Bob's chatController1.typingUsers updates: [{ userId: "alice", userName: "Alice" }] +6. ChatBox shows "Alice is typing..." indicator + +7. Alice stops typing (pauses or clears input) +8. ChatBox fires "Stop Typing" event +9. Event handler calls chatController1.stopTyping() +10. Bob's typingUsers becomes [] → indicator disappears +``` + +### Flow 6: Sending Invisible JSON Data in a Room + +This is NOT a chat message — it's arbitrary JSON that all room members can read. Use cases: live dashboards, game state, form data, IoT readings, etc. + +``` +USER A (e.g. a dashboard admin): + +1. A query returns KPI data. On success: + chatController1.setRoomData("room_sales", "kpi", { + revenue: 142000, + deals: 17, + updated: "2026-03-16T10:30:00Z" + }) + +USER B (e.g. a sales rep viewing the room): + +2. YJS auto-syncs roomData +3. Any component bound to {{ chatController1.roomData.room_sales.kpi.revenue }} + instantly shows: 142000 +4. When User A updates the KPI, User B's UI updates in real-time + +No messages. No events to wire. Just reactive data binding. +``` + +--- + +## API Reference + +### ChatController — Properties (read via bindings) + +| Property | Type | Description | +|---|---|---| +| `ready` | `boolean` | `true` when connected to Pluv | +| `connectionStatus` | `string` | `"Online"`, `"Connecting..."`, or `"Offline"` | +| `error` | `string \| null` | Error message if connection failed | +| `userId` | `string` | Current user ID | +| `userName` | `string` | Current user name | +| `applicationId` | `string` | Application scope ID | +| `currentRoomId` | `string \| null` | Currently active room | +| `onlineUsers` | `Array<{ userId, userName, currentRoomId }>` | Who's online | +| `typingUsers` | `Array<{ userId, userName, roomId }>` | Who's typing | +| `aiThinkingRooms` | `{ [roomId]: boolean }` | Which rooms have AI thinking | +| `sharedState` | `object` | App-level shared JSON — auto-syncs across all users | +| `roomData` | `{ [roomId]: { [key]: value } }` | Room-scoped shared JSON — auto-syncs | + +### ChatController — Methods (call from event handlers) + +| Method | Params | Description | +|---|---|---| +| `setSharedState(key, value)` | `key: string`, `value: any` | Write to app-level shared state. All users see the update instantly. | +| `deleteSharedState(key)` | `key: string` | Remove a key from shared state. | +| `setRoomData(roomId, key, value)` | `roomId: string`, `key: string`, `value: any` | Write JSON scoped to a room. Not visible as a chat message. | +| `deleteRoomData(roomId, key?)` | `roomId: string`, `key?: string` | Remove a key (or all data) from a room. | +| `switchRoom(roomId)` | `roomId: string` | Set the active room. Updates presence and fires `roomSwitched`. | +| `startTyping(roomId?)` | `roomId?: string` | Show typing indicator to other users. | +| `stopTyping()` | — | Hide typing indicator. | +| `setAiThinking(roomId, isThinking)` | `roomId: string`, `isThinking: boolean` | Show/hide AI thinking animation for a room. | +| `setUser(userId, userName)` | `userId: string`, `userName: string` | Update user credentials at runtime. | + +### ChatController — Events + +| Event | When it fires | +|---|---| +| `Connected` | WebSocket connection established | +| `Disconnected` | WebSocket connection lost | +| `Error` | Connection error occurred | +| `User Joined` | A new user came online | +| `User Left` | A user went offline | +| `Room Switched` | Active room changed (after `switchRoom()`) | +| `Shared State Changed` | Any key in `sharedState` was updated by any user | +| `Room Data Changed` | Any key in `roomData` was updated by any user | +| `AI Thinking Started` | AI started generating in a room | +| `AI Thinking Stopped` | AI finished generating in a room | + +### ChatBox — Properties (set in property panel) + +| Property | Binding | Description | +|---|---|---| +| `messages` | `{{loadMessages.data}}` | Array of message objects | +| `rooms` | `{{chatController1.sharedState.rooms \|\| []}}` | Array of room objects | +| `currentRoomId` | `{{chatController1.currentRoomId}}` | Active room ID | +| `currentUserId` | `{{chatController1.userId}}` | Current user's ID | +| `currentUserName` | `{{chatController1.userName}}` | Current user's name | +| `typingUsers` | `{{chatController1.typingUsers}}` | Users currently typing | +| `onlineUsers` | `{{chatController1.onlineUsers}}` | Users currently online | +| `isAiThinking` | `{{chatController1.aiThinkingRooms[chatController1.currentRoomId]}}` | AI thinking state | +| `showRoomsPanel` | `true` / `false` | Toggle room sidebar | +| `allowRoomCreation` | `true` / `false` | Show create-room button | + +### ChatBox — Events + +| Event | What to do | +|---|---| +| `Message Sent` | Run `saveMessage` query, then update roomData | +| `Start Typing` | Call `chatController1.startTyping()` | +| `Stop Typing` | Call `chatController1.stopTyping()` | +| `Room Switch` | Call `chatController1.switchRoom(chatBox1.pendingRoomId)` | +| `Room Create` | Run `createRoom` query, reload rooms, update sharedState | +| `Room Join` | Add user to room members in DB, reload rooms | +| `Room Leave` | Remove user from room members, reload rooms | + +### ChatBox — Exposed State (read from other components) + +| Property | Description | +|---|---| +| `lastSentMessageText` | The text of the last message the user sent | +| `messageText` | Current text in the input field | +| `pendingRoomId` | Room ID from the last room switch/join/leave click | +| `newRoomName` | Room name from the create-room form | +| `newRoomType` | Room type from the create-room form (`public` / `private` / `llm`) | +| `newRoomDescription` | Description from the create-room form | +| `inviteTargetUserId` | User ID from the invite form | +| `pendingInviteId` | Invite ID from accept/decline | + +--- + +## Data Shapes + +### Message Object + +```json +{ + "id": "msg_1710600005000_a3kf8j2", + "roomId": "room_general", + "text": "Hello everyone!", + "authorId": "user_alice", + "authorName": "Alice", + "timestamp": 1710600005000, + "authorType": "user" +} +``` + +`authorType` is optional. Set to `"assistant"` for AI/bot messages to render them with a different bubble style. + +### Room Object + +```json +{ + "id": "room_general", + "name": "General", + "type": "public", + "description": "Main chat room", + "members": ["user_alice", "user_bob"], + "createdBy": "user_alice", + "createdAt": 1710600000000, + "llmQueryName": null +} +``` + +`type` can be `"public"`, `"private"`, or `"llm"` (for AI-powered rooms). + +--- + +## Shared State vs Room Data — When to Use Which + +| Scenario | Use | Example | +|---|---|---| +| Room list visible to all users | `setSharedState("rooms", [...])` | Syncs the room sidebar | +| App-wide config or settings | `setSharedState("config", {...})` | Theme, feature flags | +| Any app-wide data | `setSharedState("myKey", value)` | Announcements, counters | +| Invisible JSON data in a room | `setRoomData(roomId, "key", {...})` | KPI dashboard, game state | +| Signal that a message was sent | `setRoomData(roomId, "lastMessage", {...})` | Triggers other users to reload | +| IoT / live sensor data in a room | `setRoomData(roomId, "sensors", {...})` | Real-time feeds | + +**Rule of thumb**: If ALL users need it regardless of room → `sharedState`. If it's scoped to a specific room/channel → `roomData`. + +--- + +## Memory and Performance Notes + +- **sharedState** and **roomData** use YJS CRDT (via Pluv.io). The data is kept in memory on each connected client. +- Keep shared data small — room metadata, config, signals. A few KB is ideal, up to ~100KB is fine. +- **Do NOT put full message history into shared state.** Messages belong in MongoDB. YJS is for small, frequently-updated JSON that needs real-time sync. +- When you overwrite a key (`setRoomData("room_1", "kpi", newData)`), YJS garbage-collects the old value. The doc size stays proportional to current data, not history. +- Each user downloads the full YJS doc on connect. For a typical chat app with ~10-20 rooms and small per-room data, the doc is under 10KB. + +--- + +## Troubleshooting + +### ChatController shows "Connecting..." forever + +- Check that `pluv-server.js` is running and reachable +- Verify `VITE_PLUV_PUBLIC_KEY` is set correctly +- Check browser console for auth errors +- If using a proxy (Vite dev server), ensure `/api/auth/pluv` is proxied to port 3006 + +### Messages don't appear for other users + +- Verify the `saveMessage` query is succeeding (check query results) +- Verify you're calling `setRoomData(roomId, "lastMessage", ...)` after save +- Verify the ChatController has a `Room Data Changed` event handler that runs `loadMessages` +- Make sure both users have the same `applicationId` (they must be in the same Pluv room) + +### Rooms don't sync across users + +- After creating a room, you must call `chatController1.setSharedState("rooms", loadRooms.data)` +- The rooms don't come from the DB automatically — you push them to shared state, then YJS syncs them + +### "Room Data Changed" fires but loadMessages returns empty + +- Check that `chatController1.currentRoomId` is set (user must have switched to a room) +- Check that the `loadMessages` query filter uses `chatController1.currentRoomId` + +--- + +## Quick Start Checklist + +1. [ ] Pluv.io account created, keys obtained +2. [ ] `pluv-server.js` running with env vars set +3. [ ] MongoDB data source configured in Lowcoder +4. [ ] `rooms` and `messages` collections created in MongoDB +5. [ ] Seed room inserted (`room_general`) +6. [ ] Queries created: `loadRooms`, `loadMessages`, `saveMessage`, `createRoom` +7. [ ] ChatController added, configured with applicationId / userId / userName +8. [ ] ChatBox added, properties bound to ChatController + queries +9. [ ] ChatBox events wired: messageSent, startTyping, stopTyping, roomSwitch, roomCreate +10. [ ] ChatController events wired: connected, roomSwitched, roomDataChanged +11. [ ] Open app in two browser windows with different users — test sending messages diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/UPDATED_MESSAGE_SENT_EXAMPLE.js b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/UPDATED_MESSAGE_SENT_EXAMPLE.js new file mode 100644 index 000000000..05fc760ff --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/UPDATED_MESSAGE_SENT_EXAMPLE.js @@ -0,0 +1,43 @@ +// UPDATED MessageSent Query Code +// Replaces broadcastNewMessage with setRoomData + +const currentRoomId = chatControllerSignal1.currentRoomId; +const rooms = chatControllerSignal1.sharedState?.rooms || []; +const currentRoom = rooms.find(r => r.id === currentRoomId); + +console.log("CURRENT ROOM", currentRoom); + +saveMessage.run() + .then(() => { + // Check if current room is an LLM room + if (currentRoom && currentRoom.type === 'llm') { + console.log("STARTING AI THINKING..."); + // Broadcast to all users: AI is thinking + chatControllerSignal1.setAiThinking(currentRoomId, true); + return getAIResponse.run(); + } + }) + .then(() => { + // AI finished - stop thinking animation + if (currentRoom && currentRoom.type === 'llm') { + console.log("AI THINKING STOPPED"); + chatControllerSignal1.setAiThinking(currentRoomId, false); + } + + // NEW: Signal other users that a message was saved + // This triggers their "Room Data Changed" event which reloads messages + chatControllerSignal1.setRoomData(currentRoomId, "lastMessage", { + ts: Date.now(), + authorId: chatControllerSignal1.userId + }); + + // Reload your own messages + return loadMessages.run(); + }) + .catch(err => { + // Stop thinking on error so it doesn't get stuck + if (currentRoom && currentRoom.type === 'llm') { + chatControllerSignal1.setAiThinking(currentRoomId, false); + } + console.error("Error:", err); + }); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx new file mode 100644 index 000000000..0c6558e59 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -0,0 +1,360 @@ +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, + TextStyle, +} from "comps/controls/styleControlConstants"; +import { hiddenPropertyView } from "comps/utils/propertyUtils"; +import { EditorContext } from "comps/editorState"; + +import { ChatBoxView } from "./components/ChatBoxView"; +import { ChatBoxContext } from "./ChatBoxContext"; +import type { ChatRoom, PendingRoomInvite } from "./store"; + +// ─── Events ────────────────────────────────────────────────────────────────── + +const ChatEvents = [ + { + label: "Message Sent", + value: "messageSent", + description: + "Triggered when the user presses send. Read chatBox.lastSentMessageText to get the message content.", + }, + { + label: "Start Typing", + value: "startTyping", + description: + "Triggered when the user starts typing. Wire this to chatController.startTyping().", + }, + { + label: "Stop Typing", + value: "stopTyping", + description: + "Triggered when the user stops typing. Wire this to chatController.stopTyping().", + }, + { + label: "Room Switch", + value: "roomSwitch", + description: + "User clicked a room they are already a member of. Read chatBox.pendingRoomId, then call chatController.switchRoom({{chatBox1.pendingRoomId}}).", + }, + { + label: "Room Join", + value: "roomJoin", + description: + "User wants to join a room from search results. Read chatBox.pendingRoomId, then call chatController.joinRoom({{chatBox1.pendingRoomId}}).", + }, + { + label: "Room Leave", + value: "roomLeave", + description: + "User clicked leave on a room. Read chatBox.pendingRoomId, then call chatController.leaveRoom({{chatBox1.pendingRoomId}}).", + }, + { + label: "Room Create", + value: "roomCreate", + description: + "User submitted the create-room form. Read chatBox.newRoomName, newRoomType, newRoomDescription, newRoomLlmQuery, then call chatController.createRoom(...).", + }, + { + label: "Invite Send", + value: "inviteSend", + description: + "User sent a room invite. Read chatBox.inviteTargetUserId, then call chatController.sendInvite(currentRoomId, {{chatBox1.inviteTargetUserId}}).", + }, + { + label: "Invite Accept", + value: "inviteAccept", + description: + "User accepted a pending invite. Read chatBox.pendingInviteId, then call chatController.acceptInvite({{chatBox1.pendingInviteId}}).", + }, + { + label: "Invite Decline", + value: "inviteDecline", + description: + "User declined a pending invite. Read chatBox.pendingInviteId, then call chatController.declineInvite({{chatBox1.pendingInviteId}}).", + }, +] as const; + +// ─── Children map ──────────────────────────────────────────────────────────── + +const childrenMap = { + // ── Chat content ───────────────────────────────────────────────── + chatTitle: stringExposingStateControl("chatTitle", "Chat"), + showHeader: withDefault(BoolControl, true), + messages: jsonArrayControl([]), + currentUserId: withDefault(StringControl, "user_1"), + currentUserName: withDefault(StringControl, "User"), + typingUsers: jsonArrayControl([]), + 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(TextStyle, "style"), + animationStyle: styleControl(AnimationStyle, "animationStyle"), +}; + +// ─── Property panel ────────────────────────────────────────────────────────── + +const ChatBoxPropertyView = React.memo((props: { children: any }) => { + const { children } = props; + const editorMode = useContext(EditorContext).editorModeStatus; + + return ( + <> +
+ {children.chatTitle.propertyView({ + label: "Chat Title", + tooltip: "Display title shown in the chat header", + })} + {children.messages.propertyView({ + label: "Messages", + tooltip: + 'Bind to your data query, e.g. {{ loadMessages.data }}. Expected shape: [{ id, text, authorId, authorName, timestamp }]', + })} + {children.currentUserId.propertyView({ + label: "Current User ID", + tooltip: + "The current user's ID — used to distinguish own vs. other messages. Bind to {{ chatController1.userId }}", + })} + {children.currentUserName.propertyView({ + label: "Current User Name", + tooltip: "The current user's display name", + })} +
+ +
+ {children.showRoomsPanel.propertyView({ label: "Show Rooms Panel" })} + {children.roomsPanelWidth.propertyView({ + label: "Panel Width", + tooltip: "Width of the rooms sidebar, e.g. 240px or 30%", + })} + {children.rooms.propertyView({ + label: "Rooms", + tooltip: + "Bind to {{ chatController1.userRooms }} — the list of rooms visible to the current user.", + })} + {children.currentRoomId.propertyView({ + label: "Current Room ID", + tooltip: + "Bind to {{ chatController1.currentRoomId }} to highlight the active room.", + })} + {children.pendingInvites.propertyView({ + label: "Pending Invites", + tooltip: + "Bind to {{ chatController1.pendingInvites }} to show invite notifications.", + })} + {children.allowRoomCreation.propertyView({ label: "Allow Room Creation" })} + {children.allowRoomSearch.propertyView({ label: "Allow Room Search" })} +
+ +
+ {children.typingUsers.propertyView({ + label: "Typing Users", + tooltip: + "Array of users currently typing. Bind to {{ chatController1.typingUsers }}", + })} + {children.isAiThinking.propertyView({ + label: "AI Is Thinking", + tooltip: + "Show the AI thinking animation to all users in this room. Bind to {{ chatController1.aiThinkingRooms[chatBox1.currentRoomId] }}", + })} + {children.onlineUsers.propertyView({ + label: "Online Users", + tooltip: + "Array of online users with presence. Bind to {{ chatController1.onlineUsers }}. Shape: [{ userId, userName, currentRoomId }]", + })} +
+ +
+ {children.showHeader.propertyView({ label: "Show Header" })} +
+ + {["logic", "both"].includes(editorMode) && ( +
+ {hiddenPropertyView(children)} + {children.onEvent.getPropertyView()} +
+ )} + + {["layout", "both"].includes(editorMode) && ( + <> +
+ {children.autoHeight.getPropertyView()} +
+
+ {children.style.getPropertyView()} +
+
+ {children.animationStyle.getPropertyView()} +
+ + )} + + ); +}); + +ChatBoxPropertyView.displayName = "ChatBoxV2PropertyView"; + +// ─── 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, + + 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", "Chat display title"), + new NameConfig( + "lastSentMessageText", + "Text of the last message sent by the user — use in your save query", + ), + new NameConfig("messageText", "Current text in the message input"), + new NameConfig("currentRoomId", "Currently active room ID — for AI thinking or room-scoped queries"), + new NameConfig( + "pendingRoomId", + "Room ID the user wants to switch to, join, or leave — read in roomSwitch/roomJoin/roomLeave events", + ), + new NameConfig("newRoomName", "Name entered in the create-room form"), + new NameConfig( + "newRoomType", + "Type selected in the create-room form: public | private | llm", + ), + new NameConfig("newRoomDescription", "Description entered in the create-room form"), + new NameConfig( + "newRoomLlmQuery", + "Query name entered for LLM rooms in the create-room form", + ), + new NameConfig( + "inviteTargetUserId", + "User ID entered in the invite form — read in inviteSend event", + ), + new NameConfig( + "pendingInviteId", + "Invite ID the user accepted or declined — read in inviteAccept/inviteDecline events", + ), + NameConfigHidden, +]); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx new file mode 100644 index 000000000..7e35ae757 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -0,0 +1,123 @@ +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"; + +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; + + // Count users online in the current room (peers only, not counting self) + const roomOnlineCount = useMemo(() => { + if (!ctx.currentRoomId) return 0; + return ctx.onlineUsers.filter( + (u) => u.currentRoomId === ctx.currentRoomId && u.userId !== ctx.currentUserId, + ).length + 1; // +1 for self + }, [ctx.onlineUsers, ctx.currentRoomId, ctx.currentUserId]); + + return ( + + {/* ── Rooms sidebar ───────────────────────────────────────── */} + {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 && ( + + + {roomOnlineCount} online + + )} +
+ )} + + + + { + ctx.lastSentMessageText.onChange(text); + ctx.onEvent("messageSent"); + }} + onStartTyping={() => ctx.onEvent("startTyping")} + onStopTyping={() => ctx.onEvent("stopTyping")} + onDraftChange={(text) => ctx.messageText.onChange(text)} + /> +
+ + {/* ── 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 000000000..51b51239a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx @@ -0,0 +1,218 @@ +import React, { useCallback, useState } from "react"; +import { Modal, Form, Input, Radio, Button, Space, Alert, Segmented } from "antd"; +import { + PlusOutlined, + GlobalOutlined, + LockOutlined, + RobotOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; +import type { ChatRoom } from "../store"; + +export interface CreateRoomModalProps { + open: boolean; + onClose: () => void; + onCreateRoom: ( + name: string, + type: "public" | "private" | "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 */} +
+
+ ROOM TYPE +
+ + + Normal Room +
+ ), + value: "normal", + }, + { + label: ( +
+ + AI / LLM Room +
+ ), + value: "llm", + }, + ]} + /> + + + {roomMode === "llm" && ( + } + style={{ + marginBottom: 16, + background: "#faf5ff", + border: "1px solid #e9d5ff", + borderRadius: 8, + }} + message={ + + AI Room — every user message triggers your Lowcoder query. + The AI response is broadcast to all members in real time. + + } + /> + )} + +
+ + + + + + + + + {roomMode === "normal" && ( + + + + Public + + + Private + + + + )} + + {roomMode === "llm" && ( + + Query Name{" "} + + (name of your Lowcoder query) + + + } + rules={[{ required: true, message: "A query name is required for AI rooms" }]} + extra={ + + Create a query in the bottom panel of Lowcoder and enter its exact name here. + Your query will receive{" "} + conversationHistory,{" "} + prompt, and{" "} + roomId as arguments. + + } + > + } + /> + + )} + + + + + + + +
+
+ ); +}); + +CreateRoomModal.displayName = "CreateRoomModal"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx new file mode 100644 index 000000000..dcfa28b2d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx @@ -0,0 +1,96 @@ +import React, { useCallback, useRef, useState } from "react"; +import { Button } from "antd"; +import { SendOutlined } from "@ant-design/icons"; +import { InputBarContainer, StyledTextArea } from "../styles"; + +export interface InputBarProps { + onSend: (text: string) => void; + onStartTyping: () => void; + onStopTyping: () => void; + onDraftChange: (text: string) => void; +} + +export const InputBar = React.memo((props: InputBarProps) => { + const { onSend, onStartTyping, onStopTyping, onDraftChange } = 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], + ); + + 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 000000000..adf39e5e5 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/MessageList.tsx @@ -0,0 +1,198 @@ +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 { + 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; +} + +export const MessageList = React.memo((props: MessageListProps) => { + const { messages, typingUsers, currentUserId, isAiThinking = false } = 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 ? ( + +
💬
+
No messages yet
+
Start the conversation!
+
+ ) : ( + 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)} + + )} + + ); + }) + )} + + {/* AI thinking animation — shown to all users when the LLM is generating */} + {isAiThinking && ( + + + + AI is thinking… + + + + + + + + )} + + {typingUsers.length > 0 && ( + + + + + + + + {typingUsers.length === 1 + ? `${typingUsers[0].userName || typingUsers[0].userId || "Someone"} is typing...` + : `${typingUsers.length} people are typing...`} + + + )} + +
+ ); +}); + +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 000000000..573bab1c9 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/RoomPanel.tsx @@ -0,0 +1,393 @@ +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"; + +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, + 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 ? `Join "${room.name}"` : room.name} + > + {room.type === "llm" ? ( + + ) : room.type === "public" ? ( + + ) : ( + + )} + + {room.name} + + {room.type === "llm" && !isSearch && ( + + AI + + )} + {isSearch && Join} + {isActive && !isSearch && ( + { + e?.stopPropagation(); + onRoomLeave(room.id); + }} + onCancel={(e) => e?.stopPropagation()} + okText="Leave" + cancelText="Cancel" + okButtonProps={{ danger: true }} + > + e.stopPropagation()} + style={{ fontSize: 12, opacity: 0.7 }} + /> + + )} + + ); + }; + + return ( + + + Rooms +
+ {onInviteModalOpen && ( + +
+
+ + {allowRoomSearch && ( +
+ } + value={searchQuery} + onChange={(e) => handleSearch(e.target.value)} + allowClear + onClear={clearSearch} + /> +
+ )} + + {isSearchMode && ( +
+ {searchResults.length > 0 + ? `${searchResults.length} result${searchResults.length > 1 ? "s" : ""}` + : `No public rooms match "${searchQuery}"`} + +
+ )} + + {/* Pending invites section */} + {!isSearchMode && pendingInvites.length > 0 && ( +
+
+ + Pending Invites ({pendingInvites.length}) +
+ {pendingInvites.map((invite) => ( +
+
+ + {invite.roomName} +
+
+ Invited by {invite.fromUserName} +
+
+ + +
+
+ ))} +
+ )} + + + {roomListItems.length === 0 && !isSearchMode && ( +
+ No rooms yet. + {allowRoomCreation ? " Create one!" : ""} +
+ )} + + {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 && ( + + + + Online — {roomOnlineUsers.length} + + {roomOnlineUsers.map((user) => ( + + + {(user.userName || user.userId).slice(0, 1).toUpperCase()} + + + + {user.userId === currentUserId ? `${user.userName} (You)` : user.userName} + + + ))} + + )} +
+ ); +}); + +RoomPanel.displayName = "RoomPanel"; + +// ── Avatar color helper ─────────────────────────────────────────────────────── + +const AVATAR_PALETTE = [ + "#1890ff", "#52c41a", "#fa8c16", "#722ed1", + "#eb2f96", "#13c2c2", "#faad14", "#f5222d", +]; + +function avatarColor(userId: string): string { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = userId.charCodeAt(i) + ((hash << 5) - hash); + } + return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]; +} + +// ── Section label ───────────────────────────────────────────────────────────── + +const RoomSectionLabel = React.memo(({ label }: { label: string }) => ( +
+ {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 000000000..68429247a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/index.tsx @@ -0,0 +1 @@ +export { ChatBoxV2Comp } from "./chatBoxComp"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts new file mode 100644 index 000000000..f54203df5 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts @@ -0,0 +1,22 @@ +export type { + AiThinkingState, + ChatMessage, + ChatRoom, + PendingRoomInvite, + TypingUser, + OnlineUser, +} from "./types"; + +export { uid, LLM_BOT_AUTHOR_ID } from "./types"; + +export { + PluvRoomProvider, + useStorage, + useTransact, + useMyPresence, + useMyself, + useOthers, + useRoom, + useConnection, + useDoc, +} from "./pluvClient"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts new file mode 100644 index 000000000..a113bd6f5 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/pluvClient.ts @@ -0,0 +1,61 @@ +import { createClient } from "@pluv/client"; +import { yjs } from "@pluv/crdt-yjs"; +import { createBundle } from "@pluv/react"; +import { z } from "zod"; + +// Resolve the pluv.io publishable key from the environment. +// This is set at build time via VITE_PLUV_PUBLIC_KEY, or injected at runtime +// via globalThis.__PLUV_PUBLIC_KEY__ (e.g. from a server-rendered template). +const PLUV_PUBLIC_KEY: string = + (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PLUV_PUBLIC_KEY) || + (typeof globalThis !== "undefined" && (globalThis as any).__PLUV_PUBLIC_KEY__) || + ""; + +// Auth server URL. Defaults to a relative path so the Vite dev proxy and +// production reverse-proxy both work without extra configuration. +const PLUV_AUTH_URL: string = + (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PLUV_AUTH_URL) || + "/api/auth/pluv"; + +// `metadata` is PLUV's built-in mechanism for passing per-connection data +// (like the current user) into the authEndpoint at the moment a room is +// entered. It is provided as a prop on , +// so there is no need for any global mutable config object. +const client = createClient({ + metadata: z.object({ + userId: z.string(), + userName: z.string(), + }), + publicKey: PLUV_PUBLIC_KEY, + authEndpoint: ({ room, metadata }: { room: string; metadata: { userId: string; userName: string } }) => { + const params = new URLSearchParams({ + room, + userId: metadata.userId, + userName: metadata.userName, + }); + return `${PLUV_AUTH_URL}?${params}`; + }, + initialStorage: yjs.doc((t: any) => ({ + aiActivity: t.map("aiActivity", []), + sharedState: t.map("sharedState", []), + roomData: t.map("roomData", []), + })), + presence: z.object({ + userId: z.string(), + userName: z.string(), + currentRoomId: z.string().nullable(), + typing: z.boolean(), + }), +} as any); + +export const { + PluvRoomProvider, + useStorage, + useTransact, + useMyPresence, + useMyself, + useOthers, + useRoom, + useConnection, + useDoc, +} = createBundle(client); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts new file mode 100644 index 000000000..d26e7f2f3 --- /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 000000000..5f5992de6 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts @@ -0,0 +1,433 @@ +import styled from "styled-components"; +import type { TextStyleType, AnimationStyleType } from "comps/controls/styleControlConstants"; + +export const Wrapper = styled.div<{ $style: TextStyleType; $anim: AnimationStyleType }>` + height: 100%; + display: flex; + overflow: hidden; + border-radius: ${(p) => p.$style.radius || "8px"}; + border: ${(p) => p.$style.borderWidth || "1px"} solid ${(p) => p.$style.border || "#e0e0e0"}; + background: ${(p) => p.$style.background || "#fff"}; + font-family: ${(p) => p.$style.fontFamily || "inherit"}; + ${(p) => p.$anim} +`; + +export const RoomPanelContainer = styled.div<{ $width: string }>` + width: ${(p) => p.$width}; + min-width: 160px; + border-right: 1px solid #eee; + display: flex; + flex-direction: column; + background: #fafbfc; +`; + +export const RoomPanelHeader = styled.div` + padding: 12px; + font-weight: 600; + font-size: 13px; + color: #555; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const RoomListContainer = styled.div` + flex: 1; + overflow-y: auto; + padding: 8px; +`; + +export const RoomItemStyled = styled.div<{ $active: boolean }>` + padding: 8px 10px; + margin-bottom: 4px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 6px; + background: ${(p) => (p.$active ? "#1890ff" : "#fff")}; + color: ${(p) => (p.$active ? "#fff" : "#333")}; + border: 1px solid ${(p) => (p.$active ? "#1890ff" : "#f0f0f0")}; + + &:hover { + background: ${(p) => (p.$active ? "#1890ff" : "#f5f5f5")}; + } +`; + +export const SearchResultBadge = styled.span` + font-size: 10px; + background: #e6f7ff; + color: #1890ff; + padding: 1px 6px; + border-radius: 8px; + font-weight: 500; + margin-left: auto; +`; + +export const ChatPanelContainer = styled.div` + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +`; + +export const ChatHeaderBar = styled.div` + padding: 12px 16px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const MessagesArea = styled.div` + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const MessageWrapper = styled.div<{ $own: boolean }>` + display: flex; + flex-direction: column; + align-self: ${(p) => (p.$own ? "flex-end" : "flex-start")}; + max-width: 70%; +`; + +export const Bubble = styled.div<{ $own: boolean }>` + padding: 10px 14px; + border-radius: ${(p) => (p.$own ? "16px 16px 4px 16px" : "16px 16px 16px 4px")}; + background: ${(p) => (p.$own ? "#1890ff" : "#f0f0f0")}; + color: ${(p) => (p.$own ? "#fff" : "#333")}; + font-size: 14px; + word-break: break-word; +`; + +export const BubbleMeta = styled.div<{ $own: boolean }>` + font-size: 11px; + opacity: 0.7; + margin-bottom: 2px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const BubbleTime = styled.div<{ $own: boolean }>` + font-size: 10px; + opacity: 0.6; + margin-top: 4px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const InputBarContainer = styled.div` + padding: 12px 16px; + border-top: 1px solid #eee; + display: flex; + gap: 8px; + align-items: flex-end; +`; + +export const StyledTextArea = styled.textarea` + flex: 1; + padding: 8px 14px; + border: 1px solid #d9d9d9; + border-radius: 18px; + resize: none; + min-height: 36px; + max-height: 96px; + font-size: 14px; + outline: none; + font-family: inherit; + line-height: 1.4; + &:focus { + border-color: #1890ff; + } +`; + +export const EmptyChat = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #999; + gap: 4px; +`; + +export const TypingIndicatorWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + align-self: flex-start; +`; + +export const TypingDots = styled.span` + display: inline-flex; + align-items: center; + gap: 3px; + background: #e8e8e8; + border-radius: 12px; + padding: 8px 12px; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: #999; + animation: typingBounce 1.4s infinite ease-in-out both; + } + + span:nth-child(1) { animation-delay: 0s; } + span:nth-child(2) { animation-delay: 0.2s; } + span:nth-child(3) { animation-delay: 0.4s; } + + @keyframes typingBounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } + } +`; + +export const TypingLabel = styled.span` + font-size: 12px; + color: #999; + font-style: italic; +`; + +export const ConnectionBanner = styled.div<{ $status: "online" | "offline" | "connecting" }>` + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: ${(p) => + p.$status === "online" ? "#52c41a" : p.$status === "offline" ? "#fa541c" : "#999"}; +`; + +export const ConnectionDot = styled.span<{ $status: "online" | "offline" | "connecting" }>` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${(p) => + p.$status === "online" ? "#52c41a" : p.$status === "offline" ? "#fa541c" : "#d9d9d9"}; +`; + +// ── 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; + + /* Markdown styles inside the AI bubble */ + 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; + } +`; + +/** Animated "thinking" bubble shown while the LLM query is in-flight. */ +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; } + } +`; + +/** Icon shown inside room list items for LLM rooms. */ +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 000000000..98ed240d5 --- /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 57ac9040a..39de2e739 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 3151bff6a..9bb53a72a 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 0e2fd0290..b12aafd41 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 d5b0ce187..689e0dc28 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 000000000..1f2d4580d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts @@ -0,0 +1,108 @@ +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.styles.ts + +import styled from "styled-components"; + + +export interface StyledChatContainerProps { + $autoHeight?: boolean; + $sidebarWidth?: string; + $sidebarStyle?: any; + $messagesStyle?: any; + $inputStyle?: any; + $sendButtonStyle?: any; + $newThreadButtonStyle?: any; + $threadItemStyle?: any; + $animationStyle?: any; + style?: any; +} + +export const StyledChatContainer = styled.div` + display: flex; + height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; + min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; + + /* Main container styles */ + background: ${(props) => props.style?.background || "transparent"}; + margin: ${(props) => props.style?.margin || "0"}; + padding: ${(props) => props.style?.padding || "0"}; + border: ${(props) => props.style?.borderWidth || "0"} ${(props) => props.style?.borderStyle || "solid"} ${(props) => props.style?.border || "transparent"}; + border-radius: ${(props) => props.style?.radius || "0"}; + + /* Animation styles */ + animation: ${(props) => props.$animationStyle?.animation || "none"}; + animation-duration: ${(props) => props.$animationStyle?.animationDuration || "0s"}; + animation-delay: ${(props) => props.$animationStyle?.animationDelay || "0s"}; + animation-iteration-count: ${(props) => props.$animationStyle?.animationIterationCount || "1"}; + + p { + margin: 0; + } + + /* Sidebar Styles */ + .aui-thread-list-root { + width: ${(props) => props.$sidebarWidth || "250px"}; + background-color: ${(props) => props.$sidebarStyle?.sidebarBackground || "#fff"}; + padding: 10px; + } + + .aui-thread-list-item-title { + color: ${(props) => props.$sidebarStyle?.threadText || "inherit"}; + } + + /* Messages Window Styles */ + .aui-thread-root { + flex: 1; + background-color: ${(props) => props.$messagesStyle?.messagesBackground || "#f9fafb"}; + height: auto; + } + + /* User Message Styles */ + .aui-user-message-content { + background-color: ${(props) => props.$messagesStyle?.userMessageBackground || "#3b82f6"}; + color: ${(props) => props.$messagesStyle?.userMessageText || "#ffffff"}; + } + + /* Assistant Message Styles */ + .aui-assistant-message-content { + background-color: ${(props) => props.$messagesStyle?.assistantMessageBackground || "#ffffff"}; + color: ${(props) => props.$messagesStyle?.assistantMessageText || "inherit"}; + } + + /* Input Field Styles */ + form.aui-composer-root { + background-color: ${(props) => props.$inputStyle?.inputBackground || "#ffffff"}; + color: ${(props) => props.$inputStyle?.inputText || "inherit"}; + border-color: ${(props) => props.$inputStyle?.inputBorder || "#d1d5db"}; + } + + /* Send Button Styles */ + .aui-composer-send { + background-color: ${(props) => props.$sendButtonStyle?.sendButtonBackground || "#3b82f6"} !important; + + svg { + color: ${(props) => props.$sendButtonStyle?.sendButtonIcon || "#ffffff"}; + } + } + + /* New Thread Button Styles */ + .aui-thread-list-root > button { + background-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + color: ${(props) => props.$newThreadButtonStyle?.newThreadText || "#ffffff"} !important; + border-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + } + + /* Thread item styling */ + .aui-thread-list-item { + cursor: pointer; + transition: background-color 0.2s ease; + background-color: ${(props) => props.$threadItemStyle?.threadItemBackground || "transparent"}; + color: ${(props) => props.$threadItemStyle?.threadItemText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.threadItemBorder || "transparent"}; + + &[data-active="true"] { + background-color: ${(props) => props.$threadItemStyle?.activeThreadBackground || "#dbeafe"}; + color: ${(props) => props.$threadItemStyle?.activeThreadText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.activeThreadBorder || "#bfdbfe"}; + } + } +`; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx deleted file mode 100644 index ad0d33e2c..000000000 --- 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 1c9af4f55..f4823011e 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx @@ -1,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 616acc087..a45e5fe14 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx @@ -5,7 +5,7 @@ import { MessagePrimitive, ThreadPrimitive, } from "@assistant-ui/react"; - import type { FC } from "react"; + import { useMemo, type FC } from "react"; import { trans } from "i18n"; import { ArrowDownIcon, @@ -14,7 +14,6 @@ import { ChevronRightIcon, CopyIcon, PencilIcon, - RefreshCwIcon, SendHorizontalIcon, } from "lucide-react"; import { cn } from "../../utils/cn"; @@ -54,9 +53,20 @@ import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } fr interface ThreadProps { placeholder?: string; + showAttachments?: boolean; } - export const Thread: FC = ({ placeholder = trans("chat.composerPlaceholder") }) => { + export const Thread: FC = ({ + placeholder = trans("chat.composerPlaceholder"), + showAttachments = true + }) => { + // Stable component reference so React doesn't unmount/remount on every render + const UserMessageComponent = useMemo(() => { + const Wrapper: FC = () => ; + Wrapper.displayName = "UserMessage"; + return Wrapper; + }, [showAttachments]); + return ( - + @@ -148,11 +158,18 @@ import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } fr ); }; - const Composer: FC<{ placeholder?: string }> = ({ placeholder = trans("chat.composerPlaceholder") }) => { + const Composer: FC<{ placeholder?: string; showAttachments?: boolean }> = ({ + placeholder = trans("chat.composerPlaceholder"), + showAttachments = true + }) => { return ( - - + {showAttachments && ( + <> + + + + )} { + const UserMessage: FC<{ showAttachments?: boolean }> = ({ showAttachments = true }) => { return ( - + {showAttachments && }
@@ -273,11 +290,6 @@ import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } fr - - - - - ); }; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx index 1a31222a9..e733727f3 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx @@ -360,7 +360,9 @@ export function ChatProvider({ children, storage }: { // Auto-initialize on mount useEffect(() => { + console.log("useEffect Inside ChatProvider", state.isInitialized, state.isLoading); if (!state.isInitialized && !state.isLoading) { + console.log("Initializing chat data..."); initialize(); } }, [state.isInitialized, state.isLoading]); diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx index 4406b74e6..945783c69 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx @@ -21,25 +21,25 @@ const buttonVariants = cva("aui-button", { }, }); -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean; - }) { +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + } +>(({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( ); -} +}); + +Button.displayName = "Button"; export { Button, buttonVariants }; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index 5e757f231..d24e0ce84 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -63,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 a0f7c78e0..9ff22d436 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts @@ -5,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/controls/styleControlConstants.tsx b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx index 176afbbfc..e09e2b1fc 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,14 @@ 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; + 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 000000000..db4ea4b9b --- /dev/null +++ b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx @@ -0,0 +1,686 @@ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { Section, sectionNames } from "lowcoder-design"; +import { + simpleMultiComp, + stateComp, + withDefault, + withPropertyViewFn, + withViewFn, +} from "../generators"; +import { NameConfig, withExposingConfigs } from "../generators/withExposing"; +import { withMethodExposing } from "../generators/withMethodExposing"; +import { stringExposingStateControl } from "comps/controls/codeStateControl"; +import { eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { JSONObject } from "../../util/jsonTypes"; +import { isEmpty, omit, isEqual } from "lodash"; +import { + PluvRoomProvider, + useStorage, + useMyPresence, + useOthers, + useConnection, +} from "../comps/chatBoxComponentv2/store"; +import type { + AiThinkingState, + OnlineUser, + TypingUser, +} from "../comps/chatBoxComponentv2/store"; + +// ─── Event definitions ────────────────────────────────────────────────────── + +const ChatControllerEvents = [ + { + label: "User Joined", + value: "userJoined", + description: "A user came online in this application", + }, + { + label: "User Left", + value: "userLeft", + description: "A user went offline", + }, + { + label: "Room Switched", + value: "roomSwitched", + description: "Active room changed. Read currentRoomId.", + }, + { + label: "Connected", + value: "connected", + description: "Connected to the signal server", + }, + { + label: "Disconnected", + value: "disconnected", + description: "Disconnected from the signal server", + }, + { + label: "Error", + value: "error", + description: "A connection error occurred", + }, + { + label: "AI Thinking Started", + value: "aiThinkingStarted", + description: "The AI assistant started generating a response in a room", + }, + { + label: "AI Thinking Stopped", + value: "aiThinkingStopped", + description: "The AI assistant finished (or was cancelled) in a room", + }, + { + label: "Shared State Changed", + value: "sharedStateChanged", + description: + "The app-level shared state was updated by any user. Read chatController.sharedState to get the current state.", + }, + { + label: "Room Data Changed", + value: "roomDataChanged", + description: + "Room-scoped shared data was updated by any user. Read chatController.roomData to get the current data.", + }, +] as const; + +// ─── Children map ─────────────────────────────────────────────────────────── + +const childrenMap = { + applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), + userId: stringExposingStateControl("userId", "user_1"), + userName: stringExposingStateControl("userName", "User"), + + onEvent: eventHandlerControl(ChatControllerEvents), + + ready: stateComp(false), + error: stateComp(null), + connectionStatus: stateComp("Connecting..."), + 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 Pluv hooks inside PluvRoomProvider ──────────── + +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; + initialized: boolean; + aiThinkingRooms: Record; + sharedState: JSONObject | null; + roomData: JSONObject | null; + }>({ + ready: false, + onlineCount: 0, + initialized: false, + aiThinkingRooms: {}, + sharedState: null, + roomData: null, + }); + + // ── Connection state ────────────────────────────────────────────── + const ready = connection.state === "open"; + const connectionLabel = useMemo(() => { + if (connection.state === "open") return "Online"; + if (connection.state === "connecting") return "Connecting..."; + return "Offline"; + }, [connection.state]); + + useEffect(() => { + compRef.current.children.ready.dispatchChangeValueAction(ready); + compRef.current.children.connectionStatus.dispatchChangeValueAction(connectionLabel); + 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.initialized) { + 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.initialized = 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; + const prev = prevRef.current.aiThinkingRooms[roomId] ?? false; + if (state.isThinking && !prev) { + triggerEventRef.current("aiThinkingStarted"); + } else if (!state.isThinking && prev) { + triggerEventRef.current("aiThinkingStopped"); + } + } + + prevRef.current.aiThinkingRooms = nextThinking; + compRef.current.children.aiThinkingRooms.dispatchChangeValueAction( + nextThinking as unknown as JSONObject, + ); + }, [aiActivity]); + + // ── Watch shared state ────────────────────────────────────────── + useEffect(() => { + if (!sharedStateData) return; + const next = sharedStateData as unknown as JSONObject; + if (isEqual(next, prevRef.current.sharedState)) return; + prevRef.current.sharedState = next; + compRef.current.children.sharedState.dispatchChangeValueAction(next); + if (prevRef.current.initialized) { + triggerEventRef.current("sharedStateChanged"); + } + }, [sharedStateData]); + + // ── Watch room data ────────────────────────────────────────────── + useEffect(() => { + if (!roomDataData) return; + const next = roomDataData as unknown as JSONObject; + if (isEqual(next, prevRef.current.roomData)) return; + prevRef.current.roomData = next; + compRef.current.children.roomData.dispatchChangeValueAction(next); + if (prevRef.current.initialized) { + triggerEventRef.current("roomDataChanged"); + } + }, [roomDataData]); + + // ── Actions for method invocation ───────────────────────────────── + + const startTyping = useCallback( + (roomId?: string) => { + setMyPresence({ + userId, + userName, + currentRoomId: roomId || currentRoomId || null, + typing: true, + } as any); + }, + [setMyPresence, userId, userName, currentRoomId], + ); + + const stopTyping = useCallback(() => { + setMyPresence({ + userId, + userName, + currentRoomId: currentRoomId, + typing: false, + } as any); + }, [setMyPresence, userId, userName, currentRoomId]); + + const switchRoom = useCallback( + (roomId: string) => { + compRef.current.children.currentRoomId.dispatchChangeValueAction(roomId); + setMyPresence({ + userId, + userName, + currentRoomId: roomId, + typing: false, + } as any); + 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 initial presence ────────────────────────────────────────── + useEffect(() => { + setMyPresence({ + userId, + userName, + currentRoomId: null, + typing: false, + } as any); + }, [setMyPresence, userId, userName]); + + return null; + }, +); + +SignalController.displayName = "SignalController"; + +// ─── View function (wraps PluvRoomProvider) ────────────────────────────────── + +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 ( + ({ + aiActivity: t.map("aiActivity", []), + sharedState: t.map("sharedState", []), + roomData: t.map("roomData", []), + })} + onAuthorizationFail={(error: Error) => { + console.error("[ChatControllerV2] Auth failed:", error); + comp.children.error.dispatchChangeValueAction(error.message); + comp.children.onEvent.getView()("error"); + }} + > + + + ); + }, +); + +// ─── Property panel ───────────────────────────────────────────────────────── + +const ChatControllerSignalWithProps = withPropertyViewFn( + ChatControllerSignalBase, + (comp) => ( + <> +
+ {comp.children.applicationId.propertyView({ + label: "Application ID", + tooltip: + "Scopes the signal room to this application. All users of the same app share presence and notifications.", + })} + {comp.children.userId.propertyView({ + label: "User ID", + tooltip: "Current user's unique identifier", + })} + {comp.children.userName.propertyView({ + label: "User Name", + tooltip: "Current user's display name", + })} +
+
+ {comp.children.onEvent.getPropertyView()} +
+ + ), +); + +// ─── Expose state properties ──────────────────────────────────────────────── + +let ChatControllerSignal = withExposingConfigs(ChatControllerSignalWithProps, [ + new NameConfig("ready", "Whether the signal server is connected and ready"), + new NameConfig("error", "Error message if connection failed"), + new NameConfig( + "connectionStatus", + "Current connection status (Online / Connecting... / Offline)", + ), + new NameConfig( + "onlineUsers", + "Array of currently online users: [{ userId, userName, currentRoomId }]", + ), + new NameConfig( + "typingUsers", + "Array of users currently typing: [{ userId, userName, roomId }]", + ), + new NameConfig("currentRoomId", "Currently active room/channel ID"), + new NameConfig("userId", "Current user ID"), + new NameConfig("userName", "Current user name"), + new NameConfig("applicationId", "Application scope ID"), + new NameConfig( + "aiThinkingRooms", + "Map of roomId → boolean indicating which rooms have an AI currently thinking. E.g. { 'room_123': true }", + ), + new NameConfig( + "sharedState", + "App-level shared state (JSON) that auto-syncs across all connected users. Write with setSharedState(key, value).", + ), + new NameConfig( + "roomData", + "Room-scoped shared data (JSON) that auto-syncs. Structure: { roomId: { key: value } }. Not visible as chat messages. Write with setRoomData(roomId, key, value).", + ), +]); + +// ─── Expose methods ───────────────────────────────────────────────────────── + +ChatControllerSignal = withMethodExposing(ChatControllerSignal, [ + { + method: { + name: "startTyping", + description: + "Signal that the current user started typing. Other users will see the typing indicator.", + 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: "Signal that the current user stopped typing", + params: [], + }, + execute: (comp) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.stopTyping) { + actions.stopTyping(); + } + }, + }, + { + method: { + name: "switchRoom", + description: + "Set the current room/channel context. Presence and typing will scope to this room.", + 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: + "Broadcast to all room members that the AI assistant is thinking (or has finished). All users in the room will see the thinking indicator.", + params: [ + { name: "roomId", type: "string" }, + { name: "isThinking", type: "string" }, + ], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.setAiThinking) { + const isThinking = values?.[1] === true || values?.[1] === "true"; + actions.setAiThinking(values?.[0] as string, isThinking); + } + }, + }, + { + method: { + name: "setSharedState", + description: + "Set a key-value pair in the app-level shared state. Auto-syncs to all connected users instantly via CRDT.", + params: [ + { name: "key", type: "string" }, + { name: "value", type: "JSONValue" }, + ], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.setSharedState) { + actions.setSharedState(values?.[0] as string, values?.[1]); + } + }, + }, + { + method: { + name: "deleteSharedState", + description: "Delete a key from the app-level shared state.", + params: [{ name: "key", type: "string" }], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.deleteSharedState) { + actions.deleteSharedState(values?.[0] as string); + } + }, + }, + { + method: { + name: "setRoomData", + description: + "Set a key-value pair in a room's shared data. Auto-syncs to all connected users. Not visible as a chat message — use for real-time JSON data exchange within a room/channel.", + params: [ + { name: "roomId", type: "string" }, + { name: "key", type: "string" }, + { name: "value", type: "JSONValue" }, + ], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.setRoomData) { + actions.setRoomData( + values?.[0] as string, + values?.[1] as string, + values?.[2], + ); + } + }, + }, + { + method: { + name: "deleteRoomData", + description: + "Delete a key from a room's shared data. If no key is provided, deletes all data for the room.", + params: [ + { name: "roomId", type: "string" }, + { name: "key", type: "string" }, + ], + }, + execute: (comp, values) => { + const actions = comp.children._signalActions.getView() as unknown as SignalActions; + if (actions?.deleteRoomData) { + actions.deleteRoomData( + values?.[0] as string, + values?.[1] as string | undefined, + ); + } + }, + }, + { + method: { + name: "setUser", + description: "Update the current user credentials", + params: [ + { name: "userId", type: "string" }, + { name: "userName", type: "string" }, + ], + }, + execute: (comp, values) => { + if (values?.[0]) + comp.children.userId.getView().onChange(values[0] as string); + if (values?.[1]) + comp.children.userName.getView().onChange(values[1] as string); + }, + }, +]); + +export { ChatControllerSignal }; diff --git a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx index fa4294709..f8edadb51 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx @@ -38,6 +38,7 @@ import UrlParamsHookComp from "./UrlParamsHookComp"; import { UtilsComp } from "./utilsComp"; import { ScreenInfoHookComp } from "./screenInfoComp"; import { ChatControllerComp } from "../comps/chatBoxComponent/chatControllerComp"; +import { ChatControllerSignal } from "./chatControllerV2Comp"; window._ = _; window.dayjs = dayjs; @@ -120,6 +121,7 @@ const HookMap: HookCompMapRawType = { drawer: DrawerComp, theme: ThemeComp, chatController: ChatControllerComp, + chatControllerSignal: ChatControllerSignal, }; export const HookTmpComp = withTypeAndChildren(HookMap, "title", { diff --git a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx index 22e79e6d1..537dc096d 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx @@ -19,7 +19,8 @@ const AllHookComp = [ "urlParams", "theme", "meeting", - "chatController" + "chatController", + "chatControllerSignal" ] as const; export type HookCompType = (typeof AllHookComp)[number]; @@ -54,6 +55,10 @@ const HookCompConfig: Record< category: "ui", singleton: false, }, + chatControllerSignal: { + category: "ui", + singleton: false, + }, lodashJsLib: { category: "hide", }, diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index be34b1670..b391eb3ab 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -196,6 +196,8 @@ import { ContainerComp as FloatTextContainerComp } from "./comps/containerComp/t import { ChatComp } from "./comps/chatComp"; import { ChatBoxComp } from "./comps/chatBoxComponent"; import { ChatControllerComp } from "./comps/chatBoxComponent/chatControllerComp"; +import { ChatControllerSignal } from "./hooks/chatControllerV2Comp"; +import { ChatBoxV2Comp } from "./comps/chatBoxComponentv2"; type Registry = { [key in UICompType]?: UICompManifest; @@ -973,6 +975,30 @@ export var uiCompMap: Registry = { isContainer: true, }, + chatControllerSignal: { + name: "Chat Signal Controller", + enName: "Chat Signal Controller", + description: "Signal server for real-time chat — broadcasts message activity, typing indicators, and online presence via Pluv/Yjs. Pair with Chat Box V2 and your own data queries.", + categories: ["collaboration"], + icon: CommentCompIcon, + keywords: "chatbox,chat,controller,signal,realtime,presence,typing,pluv,yjs", + comp: ChatControllerSignal, + }, + + 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,messaging,v2", + comp: ChatBoxV2Comp, + layoutInfo: { + w: 12, + h: 24, + }, + }, + // Forms form: { diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index 1de611df8..97de827b8 100644 --- a/client/packages/lowcoder/src/comps/uiCompRegistry.ts +++ b/client/packages/lowcoder/src/comps/uiCompRegistry.ts @@ -145,6 +145,8 @@ export type UICompType = | "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/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 56fa433e2..38b43aab0 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -600,6 +600,28 @@ 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", + "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 +1443,11 @@ export const en = { "chat": { // Property View Labels & Tooltips - "handlerType": "Handler Type", - "handlerTypeTooltip": "How messages are processed", "chatQuery": "Chat Query", "chatQueryPlaceholder": "Select a query to handle messages", - "modelHost": "N8N Webhook URL", - "modelHostPlaceholder": "http://localhost:5678/webhook/...", - "modelHostTooltip": "N8N webhook endpoint for processing messages", "systemPrompt": "System Prompt", "systemPromptPlaceholder": "You are a helpful assistant...", "systemPromptTooltip": "Initial instructions for the AI", - "streaming": "Enable Streaming", - "streamingTooltip": "Stream responses in real-time (when supported)", "databaseName": "Database Name", "databaseNameTooltip": "Auto-generated database name for this chat component (read-only)", @@ -1453,11 +1468,6 @@ export const en = { // Error Messages "errorUnknown": "Sorry, I encountered an error. Please try again.", - - // Handler Types - "handlerTypeQuery": "Query", - "handlerTypeN8N": "N8N Workflow", - // Section Names "messageHandler": "Message Handler", "uiConfiguration": "UI Configuration", @@ -1477,10 +1487,22 @@ 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": { diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index a558d8b8d..bfbdb7b9e 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -309,4 +309,6 @@ export const CompStateIcon: { chat: , chatBox: , chatController: , + chatControllerSignal: , + chatBoxV: , } as const; diff --git a/client/packages/lowcoder/src/util/dateTimeUtils.ts b/client/packages/lowcoder/src/util/dateTimeUtils.ts index fba3affdc..97f362118 100644 --- a/client/packages/lowcoder/src/util/dateTimeUtils.ts +++ b/client/packages/lowcoder/src/util/dateTimeUtils.ts @@ -114,3 +114,22 @@ export function timestampToHumanReadable( } return timeInfo; } + +export function parseMessageTimestamp(msg: any): dayjs.Dayjs | null { + const raw = msg.timestamp ?? msg.createdAt ?? msg.created_at ?? msg.time; + if (raw == null) return null; + const d = dayjs(raw); + return d.isValid() ? d : null; +} + +export function formatChatTime(d: dayjs.Dayjs): string { + const now = dayjs(); + const diffSeconds = now.diff(d, "second"); + + if (diffSeconds < 60) return "Just now"; + if (diffSeconds < 3600) return d.fromNow(); + if (d.isToday()) return d.format("h:mm A"); + if (d.isYesterday()) return `Yesterday ${d.format("h:mm A")}`; + if (d.isSame(now, "year")) return d.format("MMM D h:mm A"); + return d.format("MMM D, YYYY h:mm A"); +} diff --git a/client/yarn.lock b/client/yarn.lock index 7f48f5878..d0577d4bb 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3354,6 +3354,13 @@ __metadata: languageName: node linkType: hard +"@panva/hkdf@npm:^1.2.1": + version: 1.2.1 + resolution: "@panva/hkdf@npm:1.2.1" + checksum: a4a9d1812f88f02bc163b365524bbaa5239cc4711e5e7be1bda68dabae1c896cf1cd12520949b0925a6910733d1afcb25ab51fd3cf06f0f69aee988fffebf56e + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -3361,6 +3368,94 @@ __metadata: languageName: node linkType: hard +"@pluv/client@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/client@npm:4.0.1" + dependencies: + "@pluv/crdt": ^4.0.1 + "@pluv/types": ^4.0.1 + wonka: ^6.3.5 + checksum: b8a56e5a19f5fd0e7f78517b1db39cf9b88add793d71b52e4c9307d3a3ff68e2e55a437d134e0877effd0d2d765b0d919878b1a42a5fecc892f0278d2eaf6ce4 + languageName: node + linkType: hard + +"@pluv/crdt-yjs@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/crdt-yjs@npm:4.0.1" + dependencies: + "@pluv/crdt": ^4.0.1 + "@pluv/types": ^4.0.1 + "@types/node": ^25.0.9 + js-base64: ^3.7.8 + lib0: ^0.2.117 + peerDependencies: + yjs: ^13.0.0 + checksum: 286c302d5522a0f7e94e0d69fa2e9ae27029a70e6883ecc20ec2183c8e75c8096d7aa9d7706a5268426f79dc048b99737fdeab03c5700ef21923314550dbc8e5 + languageName: node + linkType: hard + +"@pluv/crdt@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/crdt@npm:4.0.1" + dependencies: + "@pluv/types": ^4.0.1 + checksum: 1cf7e0fbe22fa88eb42599ec35732e680a1981256684898c528ae76f4eaacee160c9ac62fe0189654f9f06a5a5c57011de32880984948800c28e434b57929a22 + languageName: node + linkType: hard + +"@pluv/io@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/io@npm:4.0.1" + dependencies: + "@panva/hkdf": ^1.2.1 + "@pluv/crdt": ^4.0.1 + "@pluv/types": ^4.0.1 + jose: ^6.1.3 + kleur: ^4.1.5 + wonka: ^6.3.5 + checksum: eefba25d9340a82fc31e41b2cc72ea1d4e97e3810e7ddb3d44d524e850fcb6d21ddb682bcfc68a241208b0b19ade9b03eb9f61986b97f5eb022cbe41c30ca3e0 + languageName: node + linkType: hard + +"@pluv/platform-pluv@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/platform-pluv@npm:4.0.1" + dependencies: + "@pluv/crdt": ^4.0.1 + "@pluv/io": ^4.0.1 + "@pluv/types": ^4.0.1 + "@types/node": ^25.0.9 + fast-json-stable-stringify: ^2.1.0 + hono: ^4.11.4 + zod: ^4.3.5 + checksum: 6c6c05d07e5a01063712f479f984ad2a04531650950ddca89dd433897c0f22460fea7e8f12fd67a78edb5078afb8ca5b8da18a43f1e4005744677cd767891cd0 + languageName: node + linkType: hard + +"@pluv/react@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/react@npm:4.0.1" + dependencies: + "@pluv/client": ^4.0.1 + "@pluv/crdt": ^4.0.1 + "@pluv/types": ^4.0.1 + fast-deep-equal: ^3.1.3 + peerDependencies: + "@types/react": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + checksum: 16109d29536448c25ef8b2847acb51f29883ac6f3c8f9f49086d582613b2c6ebeda464380af30c1ea6c1e780643e5251f16cf966f60214d88ae4dfd291367665 + languageName: node + linkType: hard + +"@pluv/types@npm:^4.0.1": + version: 4.0.1 + resolution: "@pluv/types@npm:4.0.1" + dependencies: + wonka: ^6.3.5 + checksum: c52f30d124a236cefd4d91564e7920661fcd6e3f6ce49803d169be0bf5fb5044039deaca024854e65ced51b589d7f06a57437eb169650fa0a474734bfc7b061e + languageName: node + linkType: hard + "@polka/url@npm:^1.0.0-next.24": version: 1.0.0-next.29 resolution: "@polka/url@npm:1.0.0-next.29" @@ -5560,6 +5655,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^25.0.9": + version: 25.3.3 + resolution: "@types/node@npm:25.3.3" + dependencies: + undici-types: ~7.18.0 + checksum: 9186aae36f8ddb0b3630dba446e5c16e5f3e6c5e7a4708d117a394d3e3b6f41db2dd83a6127adf4567826776a732ca9e2561594667bce74bb18ea4d59ee1e06a + languageName: node + linkType: hard + "@types/papaparse@npm:^5.3.5": version: 5.3.16 resolution: "@types/papaparse@npm:5.3.16" @@ -6430,6 +6534,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:^2.0.0": + version: 2.0.0 + resolution: "accepts@npm:2.0.0" + dependencies: + mime-types: ^3.0.0 + negotiator: ^1.0.0 + checksum: 49fe6c050cb6f6ff4e771b4d88324fca4d3127865f2473872e818dca127d809ba3aa8fdfc7acb51dd3c5bade7311ca6b8cfff7015ea6db2f7eb9c8444d223a4f + languageName: node + linkType: hard + "accepts@npm:~1.3.4, accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" @@ -7585,6 +7699,23 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^2.2.1": + version: 2.2.2 + resolution: "body-parser@npm:2.2.2" + dependencies: + bytes: ^3.1.2 + content-type: ^1.0.5 + debug: ^4.4.3 + http-errors: ^2.0.0 + iconv-lite: ^0.7.0 + on-finished: ^2.4.1 + qs: ^6.14.1 + raw-body: ^3.0.1 + type-is: ^2.0.1 + checksum: 0b8764065ff2a8c7cf3c905193b5b528d6ab5246f0df4c743c0e887d880abcc336dad5ba86d959d7efee6243a49c2c2e5b0cee43f0ccb7d728f5496c97537a90 + languageName: node + linkType: hard + "bonjour-service@npm:^1.2.1": version: 1.3.0 resolution: "bonjour-service@npm:1.3.0" @@ -7826,7 +7957,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e @@ -8462,7 +8593,14 @@ __metadata: languageName: node linkType: hard -"content-type@npm:~1.0.4, content-type@npm:~1.0.5": +"content-disposition@npm:^1.0.0": + version: 1.0.1 + resolution: "content-disposition@npm:1.0.1" + checksum: f1ee5363968e7e4c491fcd9796d3c489ab29c4ea0bfa5dcc3379a9833d6044838367cf8a11c90b179cb2a8d471279ab259119c52e0d3e4ed30934ccd56b6d694 + languageName: node + linkType: hard + +"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766 @@ -8483,6 +8621,13 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:^1.2.1": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 1ad4f9b3907c9f3673a0f0a07c0a23da7909ac6c9204c5d80a0ec102fe50ccc45f27fdf496361840d6c132c5bb0037122c0a381f856d070183d1ebe3e5e041ff + languageName: node + linkType: hard + "cookie@npm:0.7.1": version: 0.7.1 resolution: "cookie@npm:0.7.1" @@ -8490,6 +8635,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^0.7.1": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 9bf8555e33530affd571ea37b615ccad9b9a34febbf2c950c86787088eb00a8973690833b0f8ebd6b69b753c62669ea60cec89178c1fb007bf0749abed74f93e + languageName: node + linkType: hard + coolshapes-react@lowcoder-org/coolshapes-react: version: 1.0.1 resolution: "coolshapes-react@https://github.com/lowcoder-org/coolshapes-react.git#commit=0530e0e01feeba965286c1321f9c1cacb47bf587" @@ -8564,6 +8716,16 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"cors@npm:^2.8.6": + version: 2.8.6 + resolution: "cors@npm:2.8.6" + dependencies: + object-assign: ^4 + vary: ^1 + checksum: a967922b00fd17d836d21308c66ab9081d6c0f7dc019486ba1643a58281b12fc27d8c260471ddca72874b5bfe17a2d471ff8762d34f6009022ff749ec1136220 + languageName: node + linkType: hard + "cose-base@npm:^1.0.0": version: 1.0.3 resolution: "cose-base@npm:1.0.3" @@ -9482,6 +9644,18 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"debug@npm:^4.4.0, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: ^2.1.3 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 4805abd570e601acdca85b6aa3757186084a45cff9b2fa6eee1f3b173caa776b45f478b2a71a572d616d2010cea9211d0ac4a02a610e4c18ac4324bde3760834 + languageName: node + linkType: hard + "decimal.js@npm:^10.4.2, decimal.js@npm:^10.4.3": version: 10.5.0 resolution: "decimal.js@npm:10.5.0" @@ -9645,7 +9819,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"depd@npm:2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a @@ -10105,6 +10279,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"encodeurl@npm:^2.0.0, encodeurl@npm:~2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe + languageName: node + linkType: hard + "encodeurl@npm:~1.0.2": version: 1.0.2 resolution: "encodeurl@npm:1.0.2" @@ -10112,13 +10293,6 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"encodeurl@npm:~2.0.0": - version: 2.0.0 - resolution: "encodeurl@npm:2.0.0" - checksum: abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe - languageName: node - linkType: hard - "encoding-down@npm:^6.3.0": version: 6.3.0 resolution: "encoding-down@npm:6.3.0" @@ -10481,7 +10655,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"escape-html@npm:~1.0.3": +"escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" checksum: 6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24 @@ -10937,7 +11111,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"etag@npm:~1.8.1": +"etag@npm:^1.8.1, etag@npm:~1.8.1": version: 1.8.1 resolution: "etag@npm:1.8.1" checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff @@ -11076,6 +11250,42 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"express@npm:^5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" + dependencies: + accepts: ^2.0.0 + body-parser: ^2.2.1 + content-disposition: ^1.0.0 + content-type: ^1.0.5 + cookie: ^0.7.1 + cookie-signature: ^1.2.1 + debug: ^4.4.0 + depd: ^2.0.0 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + etag: ^1.8.1 + finalhandler: ^2.1.0 + fresh: ^2.0.0 + http-errors: ^2.0.0 + merge-descriptors: ^2.0.0 + mime-types: ^3.0.0 + on-finished: ^2.4.1 + once: ^1.4.0 + parseurl: ^1.3.3 + proxy-addr: ^2.0.7 + qs: ^6.14.0 + range-parser: ^1.2.1 + router: ^2.2.0 + send: ^1.1.0 + serve-static: ^2.2.0 + statuses: ^2.0.1 + type-is: ^2.0.1 + vary: ^1.1.2 + checksum: e0bc9c11fcf4e6ed29c9b0551229e8cf35d959970eb5e10ef3e48763eb3a63487251950d9bf4ef38b93085f0f33bb1fc37ab07349b8fa98a0fa5f67236d4c054 + languageName: node + linkType: hard + "extend@npm:^3.0.0, extend@npm:~3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -11329,6 +11539,20 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"finalhandler@npm:^2.1.0": + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" + dependencies: + debug: ^4.4.0 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + on-finished: ^2.4.1 + parseurl: ^1.3.3 + statuses: ^2.0.1 + checksum: e5303c4cccce46019cf0f59b07a36cc6d37549f1efe2111c16cd78e6e500d3bfd68d3b45044c9a67a0c75ad3128ee1106fae9a0152ca3c0a8ee3bf3a4a1464bb + languageName: node + linkType: hard + "find-cache-dir@npm:^4.0.0": version: 4.0.0 resolution: "find-cache-dir@npm:4.0.0" @@ -11511,6 +11735,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 38b9828352c6271e2a0dd8bdd985d0100dbbc4eb8b6a03286071dd6f7d96cfaacd06d7735701ad9a95870eb3f4555e67c08db1dcfe24c2e7bb87383c72fae1d2 + languageName: node + linkType: hard + "fs-extra@npm:^10.0.1, fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -12219,6 +12450,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.12.3 + resolution: "hono@npm:4.12.3" + checksum: ebe122249ef71d32d0ed769338d2abef2e712a4e2ea4cbe9d0c1c7148febdd67c02315b694938d0b95f68a33b4b0d02fbbed50d8573d8f0a847df6a3d0493373 + languageName: node + linkType: hard + "hotkeys-js@npm:^3.8.7": version: 3.13.10 resolution: "hotkeys-js@npm:3.13.10" @@ -12338,6 +12576,19 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: ~2.0.0 + inherits: ~2.0.4 + setprototypeof: ~1.2.0 + statuses: ~2.0.2 + toidentifier: ~1.0.1 + checksum: 155d1a100a06e4964597013109590b97540a177b69c3600bbc93efc746465a99a2b718f43cdf76b3791af994bbe3a5711002046bf668cdc007ea44cea6df7ccd + languageName: node + linkType: hard + "http-errors@npm:~1.6.2": version: 1.6.3 resolution: "http-errors@npm:1.6.3" @@ -12500,6 +12751,15 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0": + version: 0.7.2 + resolution: "iconv-lite@npm:0.7.2" + dependencies: + safer-buffer: ">= 2.1.2 < 3.0.0" + checksum: faf884c1f631a5d676e3e64054bed891c7c5f616b790082d99ccfbfd017c661a39db8009160268fd65fae57c9154d4d491ebc9c301f3446a078460ef114dc4b8 + languageName: node + linkType: hard + "icss-utils@npm:^5.0.0, icss-utils@npm:^5.1.0": version: 5.1.0 resolution: "icss-utils@npm:5.1.0" @@ -13071,6 +13331,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"is-promise@npm:^4.0.0": + version: 4.0.0 + resolution: "is-promise@npm:4.0.0" + checksum: 0b46517ad47b00b6358fd6553c83ec1f6ba9acd7ffb3d30a0bf519c5c69e7147c132430452351b8a9fc198f8dd6c4f76f8e6f5a7f100f8c77d57d9e0f4261a8a + languageName: node + linkType: hard + "is-reference@npm:1.2.1, is-reference@npm:^1.2.1": version: 1.2.1 resolution: "is-reference@npm:1.2.1" @@ -13920,6 +14187,20 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.1.3 + resolution: "jose@npm:6.1.3" + checksum: 7f51c7e77f82b70ef88ede9fd1760298bc0ffbf143b9d94f78c08462987ae61864535c1856bc6c26d335f857c7d41f4fffcc29134212c19ea929ce34a4c790f0 + languageName: node + linkType: hard + +"js-base64@npm:^3.7.8": + version: 3.7.8 + resolution: "js-base64@npm:3.7.8" + checksum: 891746b0f23aea7dd466c5ef2d349b093944a25eca6093c09b2cbb99bc47a94237c63b91623bbc203306b7c72aab5112e90378544bceef3fd0eb9ab86d7af496 + languageName: node + linkType: hard + "js-cookie@npm:^2.2.1": version: 2.2.1 resolution: "js-cookie@npm:2.2.1" @@ -14277,7 +14558,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"kleur@npm:^4.0.3": +"kleur@npm:^4.0.3, kleur@npm:^4.1.5": version: 4.1.5 resolution: "kleur@npm:4.1.5" checksum: 1dc476e32741acf0b1b5b0627ffd0d722e342c1b0da14de3e8ae97821327ca08f9fb944542fb3c126d90ac5f27f9d804edbe7c585bf7d12ef495d115e0f22c12 @@ -14614,6 +14895,19 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"lib0@npm:^0.2.117, lib0@npm:^0.2.74": + version: 0.2.117 + resolution: "lib0@npm:0.2.117" + dependencies: + isomorphic.js: ^0.2.4 + bin: + 0ecdsa-generate-keypair: bin/0ecdsa-generate-keypair.js + 0gentesthtml: bin/gentesthtml.js + 0serve: bin/0serve.js + checksum: 948a6bb292cc643bcaea948b82f72a05edb83ff172803ba0ebdbf87361f6446d2877b61611f20ccd377c7bfa0453925b27ea75db8b694abab84216c6ca50325c + languageName: node + linkType: hard + "lilconfig@npm:2.1.0": version: 2.1.0 resolution: "lilconfig@npm:2.1.0" @@ -15154,6 +15448,11 @@ coolshapes-react@lowcoder-org/coolshapes-react: "@jsonforms/core": ^3.5.1 "@lottiefiles/dotlottie-react": ^0.13.0 "@manaflair/redux-batch": ^1.0.0 + "@pluv/client": ^4.0.1 + "@pluv/crdt-yjs": ^4.0.1 + "@pluv/io": ^4.0.1 + "@pluv/platform-pluv": ^4.0.1 + "@pluv/react": ^4.0.1 "@radix-ui/react-avatar": ^1.1.10 "@radix-ui/react-dialog": ^1.1.14 "@radix-ui/react-slot": ^1.2.3 @@ -15189,6 +15488,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: coolshapes-react: lowcoder-org/coolshapes-react copy-to-clipboard: ^3.3.3 core-js: ^3.25.2 + cors: ^2.8.6 dayjs: ^1.11.13 dotenv: ^16.0.3 echarts: ^5.4.3 @@ -15198,6 +15498,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: eslint-config-react-app: ^7.0.1 eslint-plugin-only-ascii: ^0.0.0 eslint4b-prebuilt-2: ^7.32.0 + express: ^5.2.1 file-saver: ^2.0.5 github-markdown-css: ^5.1.0 hotkeys-js: ^3.8.7 @@ -15270,9 +15571,11 @@ coolshapes-react@lowcoder-org/coolshapes-react: web-vitals: ^2.1.0 ws: ^8.18.3 xlsx: ^0.18.5 + y-indexeddb: ^9.0.12 y-protocols: ^1.0.6 y-websocket: ^3.0.0 yjs: ^13.6.27 + zod: ^3.25.76 languageName: unknown linkType: soft @@ -15742,6 +16045,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"media-typer@npm:^1.1.0": + version: 1.1.0 + resolution: "media-typer@npm:1.1.0" + checksum: a58dd60804df73c672942a7253ccc06815612326dc1c0827984b1a21704466d7cde351394f47649e56cf7415e6ee2e26e000e81b51b3eebb5a93540e8bf93cbd + languageName: node + linkType: hard + "memfs@npm:^4.6.0": version: 4.17.2 resolution: "memfs@npm:4.17.2" @@ -15778,6 +16088,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: e383332e700a94682d0125a36c8be761142a1320fc9feeb18e6e36647c9edf064271645f5669b2c21cf352116e561914fd8aa831b651f34db15ef4038c86696a + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -16431,7 +16748,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"mime-db@npm:>= 1.43.0 < 2": +"mime-db@npm:>= 1.43.0 < 2, mime-db@npm:^1.54.0": version: 1.54.0 resolution: "mime-db@npm:1.54.0" checksum: e99aaf2f23f5bd607deb08c83faba5dd25cf2fec90a7cc5b92d8260867ee08dab65312e1a589e60093dc7796d41e5fae013268418482f1db4c7d52d0a0960ac9 @@ -16447,6 +16764,15 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": + version: 3.0.2 + resolution: "mime-types@npm:3.0.2" + dependencies: + mime-db: ^1.54.0 + checksum: 70b74794f408419e4b6a8e3c93ccbed79b6a6053973a3957c5cc04ff4ad8d259f0267da179e3ecae34c3edfb4bfd7528db23a101e32d21ad8e196178c8b7b75a + languageName: node + linkType: hard + "mime@npm:1.6.0, mime@npm:^1.4.1": version: 1.6.0 resolution: "mime@npm:1.6.0" @@ -17024,7 +17350,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": +"object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f @@ -17164,7 +17490,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"once@npm:^1.3.0": +"once@npm:^1.3.0, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -17473,7 +17799,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": +"parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 @@ -17565,6 +17891,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"path-to-regexp@npm:^8.0.0": + version: 8.3.0 + resolution: "path-to-regexp@npm:8.3.0" + checksum: 73e0d3db449f9899692b10be8480bbcfa294fd575be2d09bce3e63f2f708d1fccd3aaa8591709f8b82062c528df116e118ff9df8f5c52ccc4c2443a90be73e10 + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -17917,7 +18250,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"proxy-addr@npm:~2.0.7": +"proxy-addr@npm:^2.0.7, proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" dependencies: @@ -18026,6 +18359,15 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"qs@npm:^6.14.0, qs@npm:^6.14.1": + version: 6.15.0 + resolution: "qs@npm:6.15.0" + dependencies: + side-channel: ^1.1.0 + checksum: 65e797e3747fa1092e062da7b3e0684a9194e07ccab3a9467d416d2579d2feab0adf3aa4b94446e9f69ba7426589a8728f78a10a549308c97563a79d1c0d8595 + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3" @@ -18115,6 +18457,18 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"raw-body@npm:^3.0.1": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: ~3.1.2 + http-errors: ~2.0.1 + iconv-lite: ~0.7.0 + unpipe: ~1.0.0 + checksum: bf8ce8e9734f273f24d81f9fed35609dbd25c2869faa5fb5075f7ee225c0913e2240adda03759d7e72f2a757f8012d58bb7a871a80261d5140ad65844caeb5bd + languageName: node + linkType: hard + "raw-loader@npm:^4.0.2": version: 4.0.2 resolution: "raw-loader@npm:4.0.2" @@ -20244,6 +20598,19 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: ^4.4.0 + depd: ^2.0.0 + is-promise: ^4.0.0 + parseurl: ^1.3.3 + path-to-regexp: ^8.0.0 + checksum: 4c3bec8011ed10bb07d1ee860bc715f245fff0fdff991d8319741d2932d89c3fe0a56766b4fa78e95444bc323fd2538e09c8e43bfbd442c2a7fab67456df7fa5 + languageName: node + linkType: hard + "rtl-css-js@npm:^1.16.1": version: 1.16.1 resolution: "rtl-css-js@npm:1.16.1" @@ -20529,6 +20896,25 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.1 + resolution: "send@npm:1.2.1" + dependencies: + debug: ^4.4.3 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + etag: ^1.8.1 + fresh: ^2.0.0 + http-errors: ^2.0.1 + mime-types: ^3.0.2 + ms: ^2.1.3 + on-finished: ^2.4.1 + range-parser: ^1.2.1 + statuses: ^2.0.2 + checksum: 5361e3556fbc874c080a4cfbb4541e02c16221ca3c68c4f692320d38ef7e147381f805ce3ac50dfaa2129f07daa81098e2bc567e9a4d13993a92893d59a64d68 + languageName: node + linkType: hard + "serialize-javascript@npm:^4.0.0": version: 4.0.0 resolution: "serialize-javascript@npm:4.0.0" @@ -20574,6 +20960,18 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"serve-static@npm:^2.2.0": + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" + dependencies: + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + parseurl: ^1.3.3 + send: ^1.2.0 + checksum: dd71e9a316a7d7f726503973c531168cfa6a6a56a98d5c6b279c4d0d41a83a1bc6900495dc0633712b95d88ccbf9ed4f4a780a4c4c00bf84b496e9e710d68825 + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -20632,7 +21030,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"setprototypeof@npm:1.2.0": +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89 @@ -21129,6 +21527,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 + languageName: node + linkType: hard + "stealthy-require@npm:^1.1.1": version: 1.1.1 resolution: "stealthy-require@npm:1.1.1" @@ -21811,7 +22216,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"toidentifier@npm:1.0.1": +"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 @@ -22192,6 +22597,17 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"type-is@npm:^2.0.1": + version: 2.0.1 + resolution: "type-is@npm:2.0.1" + dependencies: + content-type: ^1.0.5 + media-typer: ^1.1.0 + mime-types: ^3.0.0 + checksum: 0266e7c782238128292e8c45e60037174d48c6366bb2d45e6bd6422b611c193f83409a8341518b6b5f33f8e4d5a959f38658cacfea77f0a3505b9f7ac1ddec8f + languageName: node + linkType: hard + "type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" @@ -22398,6 +22814,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 23da306c8366574adec305b06a8519ab5c7d09e3f5d16c1a98709a34fae17da09ec95198f30f86c00055e02efa8bfcc843e84e8aebeb9b8d6bb3e06afccae07a + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -22881,7 +23304,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"vary@npm:~1.1.2": +"vary@npm:^1, vary@npm:^1.1.2, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b @@ -23687,6 +24110,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"wonka@npm:^6.3.5": + version: 6.3.5 + resolution: "wonka@npm:6.3.5" + checksum: bd9f4330664ea971ddbc762275c081d5a635bcebd1c567211d43278b925f3394ad454bb33a0ef5e8beadfaad552cdbc92c018dfb96350f3895341998efa5f521 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5, word-wrap@npm:~1.2.3": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" @@ -23872,6 +24302,17 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"y-indexeddb@npm:^9.0.12": + version: 9.0.12 + resolution: "y-indexeddb@npm:9.0.12" + dependencies: + lib0: ^0.2.74 + peerDependencies: + yjs: ^13.0.0 + checksum: 0bc53723f91d322873ba44dade45dac127cc1a1be563437c7079d4c29a467c6854346d397761cf67c53e118b285e969fa284b9287f3c2bddbfff05c101b2f153 + languageName: node + linkType: hard + "y-leveldb@npm:^0.1.0": version: 0.1.2 resolution: "y-leveldb@npm:0.1.2" @@ -24039,6 +24480,20 @@ 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 + +"zod@npm:^4.3.5": + version: 4.3.6 + resolution: "zod@npm:4.3.6" + checksum: 19cec761b46bae4b6e7e861ea740f3f248e50a6671825afc8a5758e27b35d6f20ccde9942422fd5cf6f8b697f18bd05ef8bb33f5f2db112ab25cc628de2fae47 + languageName: node + linkType: hard + "zrender@npm:5.6.1, zrender@npm:^5.1.1": version: 5.6.1 resolution: "zrender@npm:5.6.1"