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..4195354fa --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx @@ -0,0 +1,196 @@ +import React, { useCallback, useContext, useEffect, useRef } from "react"; +import { Section, sectionNames } from "lowcoder-design"; +import { UICompBuilder, withDefault } from "../../generators"; +import { NameConfig, NameConfigHidden, withExposingConfigs } from "../../generators/withExposing"; +import { withMethodExposing } from "../../generators/withMethodExposing"; +import { stringExposingStateControl, arrayObjectExposingStateControl } from "comps/controls/codeStateControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { StringControl } 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 { trans } from "i18n"; + +import { ChatBoxView } from "./components/ChatBoxView"; + +// ─── Event definitions ─────────────────────────────────────────────────────── + +const ChatEvents = [ + { label: trans("chatBox.messageSent"), value: "messageSent", description: trans("chatBox.messageSentDesc") }, + { label: trans("chatBox.messageReceived"), value: "messageReceived", description: trans("chatBox.messageReceivedDesc") }, + { label: trans("chatBox.roomJoined"), value: "roomJoined", description: trans("chatBox.roomJoinedDesc") }, + { label: trans("chatBox.roomLeft"), value: "roomLeft", description: trans("chatBox.roomLeftDesc") }, + { + label: "LLM Message Received", + value: "llmMessageReceived", + description: "Fired when an AI response arrives in an LLM room", + }, +] as const; + +// ─── Children map (component properties) ───────────────────────────────────── + +const childrenMap = { + // ── Identity / connection + chatName: stringExposingStateControl("chatName", "Chat Room"), + userId: stringExposingStateControl("userId", "user_1"), + userName: stringExposingStateControl("userName", "User"), + applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), + wsUrl: withDefault(StringControl, "ws://localhost:3005"), + + // ── Room panel + allowRoomCreation: withDefault(BoolControl, true), + allowRoomSearch: withDefault(BoolControl, true), + showRoomPanel: withDefault(BoolControl, true), + roomPanelWidth: withDefault(StringControl, "220px"), + + // ── LLM settings + systemPrompt: withDefault( + StringControl, + "You are a helpful AI assistant. Answer concisely and clearly.", + ), + llmBotName: withDefault(StringControl, "AI Assistant"), + + // ── Exposed state + llmConversationHistory: arrayObjectExposingStateControl("llmConversationHistory", []), + + // ── Layout / style + autoHeight: AutoHeightControl, + onEvent: eventHandlerControl(ChatEvents), + style: styleControl(TextStyle, "style"), + animationStyle: styleControl(AnimationStyle, "animationStyle"), +}; + +// ─── Property panel ─────────────────────────────────────────────────────────── + +const ChatBoxPropertyView = React.memo((props: { children: any }) => { + const { children } = props; + const editorMode = useContext(EditorContext).editorModeStatus; + + return ( + <> +
+ {children.chatName.propertyView({ label: "Chat Name", tooltip: "Display name for the chat header" })} + {children.userId.propertyView({ label: "User ID", tooltip: "Current user's unique identifier" })} + {children.userName.propertyView({ label: "User Name", tooltip: "Current user's display name" })} + {children.applicationId.propertyView({ label: "Application ID", tooltip: "Scopes rooms to this application" })} + {children.wsUrl.propertyView({ + label: "WebSocket URL", + tooltip: "Yjs WebSocket server URL for real-time sync (e.g. ws://localhost:3005)", + })} +
+ +
+ {children.allowRoomCreation.propertyView({ label: "Allow Room Creation" })} + {children.allowRoomSearch.propertyView({ label: "Allow Room Search" })} + {children.showRoomPanel.propertyView({ label: "Show Room Panel" })} + {children.roomPanelWidth.propertyView({ label: "Panel Width", tooltip: "e.g. 220px or 25%" })} +
+ +
+ {children.systemPrompt.propertyView({ + label: "System Prompt", + tooltip: + "Prepended to the conversation history sent to your query. Tells the AI how to behave.", + })} + {children.llmBotName.propertyView({ + label: "AI Bot Name", + tooltip: "Display name shown on AI messages in LLM rooms.", + })} +
+ + {["logic", "both"].includes(editorMode) && ( +
+ {hiddenPropertyView(children)} + {children.onEvent.getPropertyView()} +
+ )} + + {["layout", "both"].includes(editorMode) && ( + <> +
+ {children.autoHeight.getPropertyView()} +
+
+ {children.style.getPropertyView()} +
+
+ {children.animationStyle.getPropertyView()} +
+ + )} + + ); +}); + +ChatBoxPropertyView.displayName = "ChatBoxV2PropertyView"; + +// ─── Build component ────────────────────────────────────────────────────────── + +let ChatBoxV2Tmp = (function () { + return new UICompBuilder(childrenMap, (props, dispatch) => { + // Keep a ref to the latest onChange so the callback below never changes + // identity — preventing the infinite re-render loop that occurs when + // calling onChange updates llmConversationHistory, which creates a new + // props.llmConversationHistory reference, which would recreate this + // callback, which would re-trigger the useEffect in ChatBoxView, etc. + const onChangeRef = useRef(props.llmConversationHistory.onChange); + useEffect(() => { + onChangeRef.current = props.llmConversationHistory.onChange; + }); + + const onConversationHistoryChange = useCallback((history: any[]) => { + onChangeRef.current(history); + }, []); + + return ( + + ); + }) + .setPropertyViewFn((children) => ) + .build(); +})(); + +ChatBoxV2Tmp = class extends ChatBoxV2Tmp { + override autoHeight(): boolean { + return this.children.autoHeight.getView(); + } +}; + +// ─── Methods ───────────────────────────────────────────────────────────────── + +ChatBoxV2Tmp = withMethodExposing(ChatBoxV2Tmp, [ + { + method: { + name: "setUser", + description: "Update the current chat user", + params: [ + { name: "userId", type: "string" }, + { name: "userName", type: "string" }, + ], + }, + execute: (comp: any, values: any[]) => { + if (values[0]) comp.children.userId.getView().onChange(values[0]); + if (values[1]) comp.children.userName.getView().onChange(values[1]); + }, + }, +]); + +// ─── Exposing configs ───────────────────────────────────────────────────────── + +export const ChatBoxV2Comp = withExposingConfigs(ChatBoxV2Tmp, [ + new NameConfig("chatName", "Chat display name"), + new NameConfig("userId", "Current user ID"), + new NameConfig("userName", "Current user name"), + new NameConfig("applicationId", "Application scope"), + new NameConfig("llmConversationHistory", "Conversation history for the active LLM room (role + content array)"), + NameConfigHidden, +]); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx new file mode 100644 index 000000000..9a3846900 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/ChatBoxView.tsx @@ -0,0 +1,215 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { UserOutlined, RobotOutlined } from "@ant-design/icons"; +import { Tag } from "antd"; +import { useChatStore } from "../useChatStore"; +import { Wrapper, ChatPanelContainer, ChatHeaderBar, ConnectionBanner, ConnectionDot } from "../styles"; +import { RoomPanel } from "./RoomPanel"; +import { MessageList } from "./MessageList"; +import { InputBar } from "./InputBar"; +import { CreateRoomModal } from "./CreateRoomModal"; +import { LLM_BOT_AUTHOR_ID } from "../store"; + +type ChatBoxEventName = "messageSent" | "messageReceived" | "roomJoined" | "roomLeft" | "llmMessageReceived"; + +export interface ChatBoxViewProps { + chatName: { value: string }; + userId: { value: string }; + userName: { value: string }; + applicationId: { value: string }; + wsUrl: string; + allowRoomCreation: boolean; + allowRoomSearch: boolean; + showRoomPanel: boolean; + roomPanelWidth: string; + systemPrompt: string; + llmBotName: string; + style: any; + animationStyle: any; + onEvent: (event: ChatBoxEventName) => any; + onConversationHistoryChange: (history: any[]) => void; + dispatch?: (...args: any[]) => void; + [key: string]: any; +} + +function connectionStatus(ready: boolean, label: string): "online" | "offline" | "connecting" { + if (!ready) return "connecting"; + if (label.includes("Online") || label === "Local") return "online"; + return "offline"; +} + +export const ChatBoxView = React.memo((props: ChatBoxViewProps) => { + const { + chatName, + userId, + userName, + applicationId, + wsUrl, + allowRoomCreation, + allowRoomSearch, + showRoomPanel, + roomPanelWidth, + systemPrompt, + llmBotName, + style, + animationStyle, + onEvent, + onConversationHistoryChange, + dispatch, + } = props; + + const chat = useChatStore({ + applicationId: applicationId.value || "lowcoder_app", + userId: userId.value || "user_1", + userName: userName.value || "User", + wsUrl: wsUrl || "ws://localhost:3005", + dispatch, + systemPrompt, + llmBotName: llmBotName || "AI Assistant", + }); + + const [createModalOpen, setCreateModalOpen] = useState(false); + + const isLlmRoom = chat.currentRoom?.type === "llm"; + + // ── Sync conversation history exposed state for LLM rooms ──────────────── + useEffect(() => { + if (!isLlmRoom) return; + const history = chat.messages + .filter((m) => m.authorType === "user" || m.authorType === "assistant" || m.authorId === LLM_BOT_AUTHOR_ID) + .map((m) => ({ + role: m.authorType === "assistant" || m.authorId === LLM_BOT_AUTHOR_ID ? "assistant" : "user", + content: m.text, + timestamp: m.timestamp, + authorName: m.authorName, + })); + onConversationHistoryChange(history); + }, [chat.messages, isLlmRoom, onConversationHistoryChange]); + + // ── Fire messageReceived event when a new AI message lands ─────────────── + const lastMessageRef = React.useRef(null); + useEffect(() => { + const lastMsg = chat.messages[chat.messages.length - 1]; + if (!lastMsg) return; + if (lastMsg.id === lastMessageRef.current) return; + lastMessageRef.current = lastMsg.id; + + if (lastMsg.authorId === LLM_BOT_AUTHOR_ID || lastMsg.authorType === "assistant") { + onEvent("llmMessageReceived"); + onEvent("messageReceived"); + } + }, [chat.messages, onEvent]); + + // ── Room actions ────────────────────────────────────────────────────────── + const handleLeaveRoom = useCallback( + async (roomId: string) => { + const ok = await chat.leaveRoom(roomId); + if (ok) onEvent("roomLeft"); + }, + [chat.leaveRoom, onEvent], + ); + + const handleJoinRoom = useCallback( + async (roomId: string) => { + const ok = await chat.joinRoom(roomId); + if (ok) onEvent("roomJoined"); + }, + [chat.joinRoom, onEvent], + ); + + const handleSend = useCallback( + async (text: string): Promise => { + const ok = await chat.sendMessage(text); + return ok; + }, + [chat.sendMessage], + ); + + const status = connectionStatus(chat.ready, chat.connectionLabel); + + return ( + + {showRoomPanel && ( + setCreateModalOpen(true)} + /> + )} + + + +
+
+ {chatName.value} + {isLlmRoom && ( + } + color="purple" + style={{ fontSize: 11, borderRadius: 6 }} + > + AI Room + + )} +
+
+ {chat.currentRoom?.name || "No room selected"} + {chat.currentRoomMembers.length > 0 && ( + + + {chat.currentRoomMembers.length} + + )} + {isLlmRoom && chat.currentRoom?.llmQueryName && ( + + ↳ {chat.currentRoom.llmQueryName} + + )} +
+
+ + + {chat.ready ? chat.connectionLabel : chat.error || "Connecting..."} + +
+ + + + onEvent("messageSent")} + isLlmLoading={chat.isLlmLoading} + isLlmRoom={isLlmRoom} + /> +
+ + setCreateModalOpen(false)} + onCreateRoom={chat.createRoom} + onRoomCreatedEvent={() => onEvent("roomJoined")} + /> +
+ ); +}); + +ChatBoxView.displayName = "ChatBoxV2View"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/CreateRoomModal.tsx new file mode 100644 index 000000000..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..bb72fe9b1 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/components/InputBar.tsx @@ -0,0 +1,113 @@ +import React, { useCallback, useRef, useState } from "react"; +import { Button } from "antd"; +import { SendOutlined } from "@ant-design/icons"; +import type { ChatRoom } from "../store"; +import { InputBarContainer, StyledTextArea } from "../styles"; + +export interface InputBarProps { + ready: boolean; + currentRoom: ChatRoom | null; + onSend: (text: string) => Promise; + onStartTyping: () => void; + onStopTyping: () => void; + onMessageSentEvent: () => void; + isLlmLoading?: boolean; + isLlmRoom?: boolean; +} + +export const InputBar = React.memo((props: InputBarProps) => { + const { ready, currentRoom, onSend, onStartTyping, onStopTyping, onMessageSentEvent, isLlmLoading, isLlmRoom } = props; + const isDisabled = !ready || !currentRoom || !!isLlmLoading; + const [draft, setDraft] = useState(""); + const typingTimeoutRef = useRef | null>(null); + const isTypingRef = useRef(false); + + const clearTypingTimeout = useCallback(() => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + }, []); + + const handleStopTyping = useCallback(() => { + clearTypingTimeout(); + if (isTypingRef.current) { + isTypingRef.current = false; + onStopTyping(); + } + }, [onStopTyping, clearTypingTimeout]); + + const handleSend = useCallback(async () => { + if (!draft.trim()) return; + handleStopTyping(); + const ok = await onSend(draft); + if (ok) { + setDraft(""); + onMessageSentEvent(); + } + }, [draft, onSend, onMessageSentEvent, handleStopTyping]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setDraft(value); + + if (!value.trim()) { + handleStopTyping(); + return; + } + + if (!isTypingRef.current) { + isTypingRef.current = true; + onStartTyping(); + } + + clearTypingTimeout(); + typingTimeoutRef.current = setTimeout(() => { + handleStopTyping(); + }, 2000); + }, + [onStartTyping, handleStopTyping, clearTypingTimeout], + ); + + return ( + + + + + )} + + + {roomListItems.length === 0 && !isSearchMode && ready && ( +
+ No rooms yet. Create or search for one. +
+ )} + + {roomListItems.map((room) => { + const isActive = currentRoomId === room.id; + const isSearch = isSearchMode; + + return ( + { + if (isSearch) { + handleJoinAndClear(room.id); + } else if (!isActive) { + onSwitchRoom(room.id); + } + }} + title={isSearch ? `Join "${room.name}"` : room.name} + > + {room.type === "llm" ? ( + + ) : room.type === "public" ? ( + + ) : ( + + )} + + {room.name} + + {room.type === "llm" && !isSearch && ( + + AI + + )} + {isSearch && Join} + {isActive && !isSearch && ( + { + e?.stopPropagation(); + onLeaveRoom(room.id); + }} + onCancel={(e) => e?.stopPropagation()} + okText="Leave" + cancelText="Cancel" + okButtonProps={{ danger: true }} + > + e.stopPropagation()} + style={{ fontSize: 12, opacity: 0.7 }} + /> + + )} + + ); + })} +
+ + ); +}); + +RoomPanel.displayName = "RoomPanel"; 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/ChatStore.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts new file mode 100644 index 000000000..97eb95cca --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/ChatStore.ts @@ -0,0 +1,502 @@ +import * as Y from "yjs"; +import { WebsocketProvider } from "y-websocket"; +import alasql from "alasql"; +import type { + ChatMessage, + ChatRoom, + RoomMember, + TypingUser, + ChangeType, + ChatStoreListener, +} from "./types"; +import { uid, LLM_BOT_AUTHOR_ID } from "./types"; + +const PERSIST_DEBOUNCE_MS = 500; + +/** + * Unified chat store backed by YJS (real-time CRDT sync) and AlaSQL + * (browser-local persistence). On init the AlaSQL data seeds the YJS + * doc so state survives page reloads even without a server. YJS map + * observers write changes back to AlaSQL automatically. + * + * LLM rooms: rooms with type === "llm" store a llmQueryName field. The + * hook layer (useChatStore) is responsible for firing the query and + * writing the AI response back as a message with authorType === "assistant". + * All connected clients see the response via YJS sync automatically. + */ +export class ChatStore { + private ydoc: Y.Doc | null = null; + private wsProvider: WebsocketProvider | null = null; + private messagesMap: Y.Map | null = null; + private roomsMap: Y.Map | null = null; + private membersMap: Y.Map | null = null; + private awarenessHandler: (() => void) | null = null; + + private listeners = new Set(); + private ready = false; + private wsConnected = false; + + private dbName: string; + private dbReady = false; + private persistTimer: ReturnType | null = null; + + private applicationId: string; + private wsUrl: string; + + private static docs = new Map(); + private static providers = new Map(); + private static refCounts = new Map(); + + constructor(applicationId: string, wsUrl: string) { + this.applicationId = applicationId; + this.wsUrl = wsUrl; + this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`; + } + + isReady(): boolean { + return this.ready; + } + + getConnectionLabel(): string { + if (!this.ready) return "Connecting..."; + return this.wsConnected ? "Online" : "Offline (local)"; + } + + async init(): Promise { + if (this.ready) return; + + await this.initDb(); + + const docId = `chatv2_${this.applicationId}`; + let ydoc = ChatStore.docs.get(docId); + let isNewDoc = false; + + if (!ydoc) { + ydoc = new Y.Doc(); + ChatStore.docs.set(docId, ydoc); + ChatStore.refCounts.set(docId, 1); + isNewDoc = true; + console.log(`[YJS] Created new document: ${docId}`); + } else { + ChatStore.refCounts.set( + docId, + (ChatStore.refCounts.get(docId) || 0) + 1, + ); + console.log(`[YJS] Reusing existing document: ${docId}`); + } + + this.ydoc = ydoc; + this.messagesMap = ydoc.getMap("messages"); + this.roomsMap = ydoc.getMap("rooms"); + this.membersMap = ydoc.getMap("members"); + + if (isNewDoc) { + await this.hydrateFromDb(); + } + + let wsProvider = ChatStore.providers.get(docId); + if (!wsProvider) { + console.log(`[YJS] Creating WebSocket provider for ${docId} at ${this.wsUrl}`); + wsProvider = new WebsocketProvider(this.wsUrl, docId, ydoc, { + connect: true, + }); + ChatStore.providers.set(docId, wsProvider); + } + this.wsProvider = wsProvider; + + this.messagesMap.observe(() => { + this.schedulePersist(); + this.notify(new Set(["messages"])); + }); + this.roomsMap.observe(() => { + this.schedulePersist(); + this.notify(new Set(["rooms"])); + }); + this.membersMap.observe(() => { + this.schedulePersist(); + this.notify(new Set(["members"])); + }); + + const awarenessHandler = () => this.notify(new Set(["typing"])); + wsProvider.awareness.on("change", awarenessHandler); + this.awarenessHandler = awarenessHandler; + + wsProvider.on("status", (e: { status: string }) => { + this.wsConnected = e.status === "connected"; + this.notify(new Set(["connection"])); + }); + this.wsConnected = wsProvider.wsconnected; + + this.ready = true; + console.log(`[YJS] ChatStore initialized for ${this.applicationId} (${this.wsConnected ? "online" : "offline"})`); + this.notify(new Set(["connection"])); + } + + destroy(): void { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + + this.persistToDb(); + + if (this.ydoc) { + const docId = `chatv2_${this.applicationId}`; + const count = (ChatStore.refCounts.get(docId) || 1) - 1; + if (count <= 0) { + ChatStore.providers.get(docId)?.destroy(); + ChatStore.providers.delete(docId); + ChatStore.docs.delete(docId); + ChatStore.refCounts.delete(docId); + } else { + ChatStore.refCounts.set(docId, count); + } + } + + if (this.wsProvider && this.awarenessHandler) { + this.wsProvider.awareness.setLocalStateField("typing", null); + this.wsProvider.awareness.off("change", this.awarenessHandler); + this.awarenessHandler = null; + } + + this.ydoc = null; + this.wsProvider = null; + this.messagesMap = null; + this.roomsMap = null; + this.membersMap = null; + this.listeners.clear(); + this.ready = false; + } + + subscribe(listener: ChatStoreListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(changes: Set): void { + this.listeners.forEach((fn) => fn(changes)); + } + + // ── AlaSQL persistence ───────────────────────────────────────────────── + + private async initDb(): Promise { + alasql.options.autocommit = true; + await alasql.promise( + `CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${this.dbName}`, + ); + await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${this.dbName}`); + await alasql.promise(`USE ${this.dbName}`); + + await alasql.promise(` + CREATE TABLE IF NOT EXISTS rooms ( + id STRING PRIMARY KEY, name STRING, description STRING, + type STRING, llmQueryName STRING, + creatorId STRING, createdAt NUMBER, updatedAt NUMBER + ) + `); + await alasql.promise(` + CREATE TABLE IF NOT EXISTS messages ( + id STRING PRIMARY KEY, roomId STRING, authorId STRING, + authorName STRING, text STRING, timestamp NUMBER, authorType STRING + ) + `); + await alasql.promise(` + CREATE TABLE IF NOT EXISTS members ( + roomId STRING, userId STRING, userName STRING, joinedAt NUMBER + ) + `); + + // Schema migration: add new columns to pre-existing tables that may + // not have them. AlaSQL throws if the column already exists — that's fine. + try { await alasql.promise(`ALTER TABLE rooms ADD COLUMN llmQueryName STRING`); } catch { /* already exists */ } + try { await alasql.promise(`ALTER TABLE messages ADD COLUMN authorType STRING`); } catch { /* already exists */ } + + this.dbReady = true; + } + + private async hydrateFromDb(): Promise { + if (!this.dbReady) return; + + const rooms = (await alasql.promise(`SELECT * FROM rooms`)) as ChatRoom[]; + for (const r of rooms) { + if (!this.roomsMap!.has(r.id)) this.roomsMap!.set(r.id, r); + } + + const messages = (await alasql.promise(`SELECT * FROM messages`)) as ChatMessage[]; + for (const m of messages) { + if (!this.messagesMap!.has(m.id)) this.messagesMap!.set(m.id, m); + } + + const members = (await alasql.promise(`SELECT * FROM members`)) as RoomMember[]; + for (const m of members) { + const key = `${m.roomId}::${m.userId}`; + if (!this.membersMap!.has(key)) this.membersMap!.set(key, m); + } + } + + private schedulePersist(): void { + if (this.persistTimer) return; + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + this.persistToDb(); + }, PERSIST_DEBOUNCE_MS); + } + + private persistToDb(): void { + if (!this.dbReady) return; + try { + alasql(`DELETE FROM rooms`); + this.roomsMap?.forEach((v) => { + const r = v as ChatRoom; + alasql(`INSERT INTO rooms VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ + r.id, r.name, r.description, r.type, r.llmQueryName ?? null, + r.creatorId, r.createdAt, r.updatedAt, + ]); + }); + + alasql(`DELETE FROM messages`); + this.messagesMap?.forEach((v) => { + const m = v as ChatMessage; + alasql(`INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)`, [ + m.id, m.roomId, m.authorId, m.authorName, m.text, m.timestamp, + m.authorType ?? null, + ]); + }); + + alasql(`DELETE FROM members`); + this.membersMap?.forEach((v: any) => { + alasql(`INSERT INTO members VALUES (?, ?, ?, ?)`, [ + v.roomId, v.userId, v.userName, v.joinedAt, + ]); + }); + } catch { + /* persistence is best-effort */ + } + } + + // ── Rooms ────────────────────────────────────────────────────────────── + + async createRoom( + name: string, + type: "public" | "private" | "llm", + creatorId: string, + creatorName: string, + description = "", + llmQueryName?: string, + id?: string, + ): Promise { + this.assert(); + const roomId = id ?? uid(); + const now = Date.now(); + const room: ChatRoom = { + id: roomId, name, description, type, + llmQueryName: type === "llm" ? (llmQueryName ?? "") : undefined, + creatorId, createdAt: now, updatedAt: now, + }; + console.log(`[YJS] Creating room: ${name} (${roomId}) type=${type}`); + this.ydoc!.transact(() => { + this.roomsMap!.set(roomId, room); + this.membersMap!.set(`${roomId}::${creatorId}`, { + roomId, userId: creatorId, userName: creatorName, joinedAt: now, + } as RoomMember); + }); + return room; + } + + async getRoom(roomId: string): Promise { + this.assert(); + return (this.roomsMap!.get(roomId) as ChatRoom) ?? null; + } + + async getRoomByName(name: string): Promise { + this.assert(); + for (const room of this.roomsMap!.values()) { + if ((room as ChatRoom).name === name) return room as ChatRoom; + } + return null; + } + + async getAllRooms(): Promise { + this.assert(); + const rooms: ChatRoom[] = []; + this.roomsMap!.forEach((v) => rooms.push(v as ChatRoom)); + rooms.sort((a, b) => b.updatedAt - a.updatedAt); + return rooms; + } + + async getUserRooms(userId: string): Promise { + this.assert(); + const memberRoomIds = new Set(); + this.membersMap!.forEach((v: any) => { + if (v.userId === userId) memberRoomIds.add(v.roomId); + }); + const rooms: ChatRoom[] = []; + this.roomsMap!.forEach((v) => { + const r = v as ChatRoom; + if (memberRoomIds.has(r.id)) rooms.push(r); + }); + rooms.sort((a, b) => b.updatedAt - a.updatedAt); + return rooms; + } + + async getSearchableRooms(userId: string, query: string): Promise { + this.assert(); + const memberRoomIds = new Set(); + this.membersMap!.forEach((v: any) => { + if (v.userId === userId) memberRoomIds.add(v.roomId); + }); + const lq = query.toLowerCase(); + const rooms: ChatRoom[] = []; + this.roomsMap!.forEach((v) => { + const r = v as ChatRoom; + // Only public and llm rooms are discoverable via search + if (r.type === "private") return; + if (memberRoomIds.has(r.id)) return; + if ( + r.name.toLowerCase().includes(lq) || + r.description.toLowerCase().includes(lq) + ) { + rooms.push(r); + } + }); + rooms.sort((a, b) => b.updatedAt - a.updatedAt); + return rooms; + } + + async ensureRoom( + name: string, + type: "public" | "private" | "llm", + creatorId: string, + creatorName: string, + llmQueryName?: string, + ): Promise { + let room = await this.getRoomByName(name); + if (!room) room = await this.createRoom(name, type, creatorId, creatorName, "", llmQueryName); + if (!(await this.isMember(room.id, creatorId))) + await this.joinRoom(room.id, creatorId, creatorName); + return room; + } + + // ── Membership ────────────────────────────────────────────────────────── + + async joinRoom(roomId: string, userId: string, userName: string): Promise { + this.assert(); + const key = `${roomId}::${userId}`; + if (this.membersMap!.has(key)) return true; + console.log(`[YJS] User ${userName} (${userId}) joining room ${roomId}`); + this.membersMap!.set(key, { + roomId, userId, userName, joinedAt: Date.now(), + } as RoomMember); + return true; + } + + async leaveRoom(roomId: string, userId: string): Promise { + this.assert(); + this.membersMap!.delete(`${roomId}::${userId}`); + return true; + } + + async getRoomMembers(roomId: string): Promise { + this.assert(); + const members: RoomMember[] = []; + this.membersMap!.forEach((v: any) => { + if (v.roomId === roomId) members.push(v as RoomMember); + }); + members.sort((a, b) => a.joinedAt - b.joinedAt); + return members; + } + + async isMember(roomId: string, userId: string): Promise { + this.assert(); + return this.membersMap!.has(`${roomId}::${userId}`); + } + + // ── Messages ─────────────────────────────────────────────────────────── + + async sendMessage( + roomId: string, + authorId: string, + authorName: string, + text: string, + authorType: "user" | "assistant" = "user", + id?: string, + ): Promise { + this.assert(); + const msg: ChatMessage = { + id: id ?? uid(), + roomId, + authorId, + authorName, + text, + timestamp: Date.now(), + authorType, + }; + console.log(`[YJS] ${authorType === "assistant" ? "[AI]" : "[User]"} → room ${roomId}: "${text.substring(0, 50)}${text.length > 50 ? "..." : ""}"`); + this.ydoc!.transact(() => { + this.messagesMap!.set(msg.id, msg); + const room = this.roomsMap!.get(roomId) as ChatRoom | undefined; + if (room) { + this.roomsMap!.set(roomId, { ...room, updatedAt: msg.timestamp }); + } + }); + return msg; + } + + async getMessages(roomId: string, limit = 100): Promise { + this.assert(); + const msgs: ChatMessage[] = []; + this.messagesMap!.forEach((v) => { + const m = v as ChatMessage; + if (m.roomId === roomId) msgs.push(m); + }); + msgs.sort((a, b) => a.timestamp - b.timestamp); + return msgs.slice(-limit); + } + + /** + * Returns the conversation history for an LLM room in the standard + * { role, content } format suitable for passing to AI APIs. + * The system prompt (if any) is NOT prepended here — the hook layer adds it. + */ + async getLlmConversationHistory(roomId: string): Promise> { + const messages = await this.getMessages(roomId); + return messages + .filter((m) => m.authorId !== LLM_BOT_AUTHOR_ID || m.authorType === "assistant") + .map((m) => ({ + role: (m.authorType === "assistant" ? "assistant" : "user") as "user" | "assistant", + content: m.text, + })); + } + + // ── Typing (via Awareness — ephemeral, auto-clears on disconnect) ────── + + startTyping(roomId: string, userId: string, userName: string): void { + this.wsProvider?.awareness.setLocalStateField("typing", { userId, userName, roomId }); + } + + stopTyping(_roomId: string, _userId: string): void { + this.wsProvider?.awareness.setLocalStateField("typing", null); + } + + getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] { + if (!this.wsProvider) return []; + const myClientId = this.wsProvider.awareness.clientID; + const result: TypingUser[] = []; + this.wsProvider.awareness.getStates().forEach((state, clientId) => { + if (clientId === myClientId) return; + const typing = state.typing as TypingUser | null | undefined; + if (!typing) return; + if (typing.roomId !== roomId) return; + if (excludeUserId && typing.userId === excludeUserId) return; + result.push(typing); + }); + return result; + } + + // ── Internal ──────────────────────────────────────────────────────────── + + private assert(): void { + if (!this.ready) + throw new Error("ChatStore not initialized. Call init() first."); + } +} 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..8f69ddb20 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/index.ts @@ -0,0 +1,47 @@ +export type { + ChatMessage, + ChatRoom, + RoomMember, + TypingUser, + ChangeType, + ChatStoreListener, +} from "./types"; +export { uid, LLM_BOT_AUTHOR_ID } from "./types"; +export { ChatStore } from "./ChatStore"; + +import { ChatStore } from "./ChatStore"; + +// ─── Reference-counted singleton cache ─────────────────────────────────────── + +interface CacheEntry { + store: ChatStore; + refCount: number; +} + +const storeCache = new Map(); + +export function getChatStore( + applicationId: string, + wsUrl = "ws://localhost:3005", +): ChatStore { + const entry = storeCache.get(applicationId); + if (entry) { + entry.refCount++; + return entry.store; + } + + const store = new ChatStore(applicationId, wsUrl); + storeCache.set(applicationId, { store, refCount: 1 }); + return store; +} + +export function releaseChatStore(applicationId: string): void { + const entry = storeCache.get(applicationId); + if (!entry) return; + + entry.refCount--; + if (entry.refCount <= 0) { + entry.store.destroy(); + storeCache.delete(applicationId); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts new file mode 100644 index 000000000..f6f15fd31 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/store/types.ts @@ -0,0 +1,47 @@ +export interface ChatMessage { + id: string; + roomId: string; + authorId: string; + authorName: string; + text: string; + timestamp: number; + /** Distinguishes human messages from LLM responses. Absent on legacy messages → treated as "user". */ + authorType?: "user" | "assistant"; +} + +export interface ChatRoom { + id: string; + name: string; + description: string; + /** "llm" rooms broadcast AI responses to every member via YJS. */ + type: "public" | "private" | "llm"; + /** Name of the Lowcoder query that handles LLM calls (only for type === "llm"). */ + llmQueryName?: string; + creatorId: string; + createdAt: number; + updatedAt: number; +} + +export interface RoomMember { + roomId: string; + userId: string; + userName: string; + joinedAt: number; +} + +export interface TypingUser { + userId: string; + userName: string; + roomId: string; +} + +export type ChangeType = "rooms" | "messages" | "members" | "typing" | "connection"; + +export type ChatStoreListener = (changes: Set) => void; + +/** Fixed authorId used for all AI-generated messages so they are identifiable across rooms. */ +export const LLM_BOT_AUTHOR_ID = "__llm_bot__"; + +export function uid(): string { + 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..d733a49fc --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/styles.ts @@ -0,0 +1,348 @@ +import styled from "styled-components"; +import type { TextStyleType, AnimationStyleType } from "comps/controls/styleControlConstants"; + +export const Wrapper = styled.div<{ $style: TextStyleType; $anim: AnimationStyleType }>` + height: 100%; + display: flex; + overflow: hidden; + border-radius: ${(p) => p.$style.radius || "8px"}; + border: ${(p) => p.$style.borderWidth || "1px"} solid ${(p) => p.$style.border || "#e0e0e0"}; + background: ${(p) => p.$style.background || "#fff"}; + font-family: ${(p) => p.$style.fontFamily || "inherit"}; + ${(p) => p.$anim} +`; + +export const RoomPanelContainer = styled.div<{ $width: string }>` + width: ${(p) => p.$width}; + min-width: 160px; + border-right: 1px solid #eee; + display: flex; + flex-direction: column; + background: #fafbfc; +`; + +export const RoomPanelHeader = styled.div` + padding: 12px; + font-weight: 600; + font-size: 13px; + color: #555; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const RoomListContainer = styled.div` + flex: 1; + overflow-y: auto; + padding: 8px; +`; + +export const RoomItemStyled = styled.div<{ $active: boolean }>` + padding: 8px 10px; + margin-bottom: 4px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 6px; + background: ${(p) => (p.$active ? "#1890ff" : "#fff")}; + color: ${(p) => (p.$active ? "#fff" : "#333")}; + border: 1px solid ${(p) => (p.$active ? "#1890ff" : "#f0f0f0")}; + + &:hover { + background: ${(p) => (p.$active ? "#1890ff" : "#f5f5f5")}; + } +`; + +export const SearchResultBadge = styled.span` + font-size: 10px; + background: #e6f7ff; + color: #1890ff; + padding: 1px 6px; + border-radius: 8px; + font-weight: 500; + margin-left: auto; +`; + +export const ChatPanelContainer = styled.div` + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +`; + +export const ChatHeaderBar = styled.div` + padding: 12px 16px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const MessagesArea = styled.div` + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const Bubble = styled.div<{ $own: boolean }>` + max-width: 70%; + padding: 10px 14px; + border-radius: 16px; + align-self: ${(p) => (p.$own ? "flex-end" : "flex-start")}; + background: ${(p) => (p.$own ? "#1890ff" : "#f0f0f0")}; + color: ${(p) => (p.$own ? "#fff" : "#333")}; + font-size: 14px; + word-break: break-word; +`; + +export const BubbleMeta = styled.div<{ $own: boolean }>` + font-size: 11px; + opacity: 0.7; + margin-bottom: 2px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const BubbleTime = styled.div<{ $own: boolean }>` + font-size: 10px; + opacity: 0.6; + margin-top: 4px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const InputBarContainer = styled.div` + padding: 12px 16px; + border-top: 1px solid #eee; + display: flex; + gap: 8px; + align-items: flex-end; +`; + +export const StyledTextArea = styled.textarea` + flex: 1; + padding: 8px 14px; + border: 1px solid #d9d9d9; + border-radius: 18px; + resize: none; + min-height: 36px; + max-height: 96px; + font-size: 14px; + outline: none; + font-family: inherit; + line-height: 1.4; + &:focus { + border-color: #1890ff; + } +`; + +export const EmptyChat = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #999; + gap: 4px; +`; + +export const TypingIndicatorWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + align-self: flex-start; +`; + +export const TypingDots = styled.span` + display: inline-flex; + align-items: center; + gap: 3px; + background: #e8e8e8; + border-radius: 12px; + padding: 8px 12px; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: #999; + animation: typingBounce 1.4s infinite ease-in-out both; + } + + span:nth-child(1) { animation-delay: 0s; } + span:nth-child(2) { animation-delay: 0.2s; } + span:nth-child(3) { animation-delay: 0.4s; } + + @keyframes typingBounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } + } +`; + +export const TypingLabel = styled.span` + font-size: 12px; + color: #999; + font-style: italic; +`; + +export const ConnectionBanner = styled.div<{ $status: "online" | "offline" | "connecting" }>` + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: ${(p) => + p.$status === "online" ? "#52c41a" : p.$status === "offline" ? "#fa541c" : "#999"}; +`; + +export const ConnectionDot = styled.span<{ $status: "online" | "offline" | "connecting" }>` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${(p) => + p.$status === "online" ? "#52c41a" : p.$status === "offline" ? "#fa541c" : "#d9d9d9"}; +`; + +// ── 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; +`; 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..1e07d5fe7 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/useChatStore.ts @@ -0,0 +1,445 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { routeByNameAction, executeQueryAction } from "lowcoder-core"; +import { getPromiseAfterDispatch } from "util/promiseUtils"; +import type { + ChatMessage, + ChatRoom, + RoomMember, + TypingUser, + ChangeType, +} from "./store"; +import { getChatStore, releaseChatStore, LLM_BOT_AUTHOR_ID } from "./store"; +import type { ChatStore } from "./store"; + +export interface UseChatStoreConfig { + applicationId: string; + userId: string; + userName: string; + wsUrl: string; + /** Lowcoder component dispatch — required for firing LLM queries. */ + dispatch?: (...args: any[]) => void; + /** System prompt prepended to conversation history passed to the query. */ + systemPrompt?: string; + /** Display name for AI-generated messages. */ + llmBotName?: string; +} + +export interface UseChatStoreReturn { + ready: boolean; + error: string | null; + connectionLabel: string; + isLlmLoading: boolean; + + currentRoom: ChatRoom | null; + messages: ChatMessage[]; + userRooms: ChatRoom[]; + currentRoomMembers: RoomMember[]; + typingUsers: TypingUser[]; + + sendMessage: (text: string) => Promise; + switchRoom: (roomId: string) => Promise; + createRoom: ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ) => Promise; + joinRoom: (roomId: string) => Promise; + leaveRoom: (roomId: string) => Promise; + searchRooms: (query: string) => Promise; + startTyping: () => void; + stopTyping: () => void; +} + +// ── Response extraction ──────────────────────────────────────────────────── + +/** + * Pulls a text string out of whatever the Lowcoder query returned. + * + * Supported shapes (checked in priority order): + * OpenAI / Ollama-compatible : result.choices[0].message.content + * Ollama /api/chat : result.message.content + * Anthropic : result.content[0].text + * Simple object : result.content | result.text | result.response | result.output + * chatComp style : result.message (string) + * Plain string : result + */ +function extractAiText(result: any): string { + if (!result) return "No response received."; + if (typeof result === "string") return result; + + // OpenAI / Ollama OpenAI-compat / LM Studio → choices[0].message.content + if (Array.isArray(result.choices) && result.choices.length > 0) { + const choice = result.choices[0]; + if (choice?.message?.content) return String(choice.message.content); + if (choice?.text) return String(choice.text); + } + + // Anthropic → content[0].text + if (Array.isArray(result.content) && result.content.length > 0) { + const first = result.content[0]; + if (first?.text) return String(first.text); + } + + // Ollama /api/chat native format → message.content + if (result.message && typeof result.message === "object" && result.message.content) { + return String(result.message.content); + } + if (result.message && typeof result.message === "string") return result.message; + + // Simple flat shapes (custom APIs, N8N, etc.) + if (result.content && typeof result.content === "string") return result.content; + if (result.text && typeof result.text === "string") return result.text; + if (result.response && typeof result.response === "string") return result.response; + if (result.output && typeof result.output === "string") return result.output; + if (result.answer && typeof result.answer === "string") return result.answer; + if (result.reply && typeof result.reply === "string") return result.reply; + + // Fallback — pretty-print whatever came back + try { return JSON.stringify(result, null, 2); } catch { return String(result); } +} + +// ── Hook ────────────────────────────────────────────────────────────────── + +export function useChatStore(config: UseChatStoreConfig): UseChatStoreReturn { + const { applicationId, userId, userName, wsUrl, dispatch, systemPrompt, llmBotName } = config; + + const storeRef = useRef(null); + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + const [connectionLabel, setConnectionLabel] = useState("Connecting..."); + const [isLlmLoading, setIsLlmLoading] = useState(false); + + const [currentRoom, setCurrentRoom] = useState(null); + const [messages, setMessages] = useState([]); + const [userRooms, setUserRooms] = useState([]); + const [currentRoomMembers, setCurrentRoomMembers] = useState([]); + const [typingUsers, setTypingUsers] = useState([]); + + const activeRoomIdRef = useRef(null); + const currentRoomRef = useRef(null); + + // Keep refs in sync so callbacks always see latest values without + // needing to be in dependency arrays. + const dispatchRef = useRef(dispatch); + const systemPromptRef = useRef(systemPrompt); + const llmBotNameRef = useRef(llmBotName); + useEffect(() => { dispatchRef.current = dispatch; }, [dispatch]); + useEffect(() => { systemPromptRef.current = systemPrompt; }, [systemPrompt]); + useEffect(() => { llmBotNameRef.current = llmBotName; }, [llmBotName]); + + // ── Granular refresh helpers ──────────────────────────────────────────── + + const refreshRooms = useCallback(async () => { + const store = storeRef.current; + if (!store || !userId) return; + try { + const rooms = await store.getUserRooms(userId); + setUserRooms(rooms); + } catch { /* non-fatal */ } + }, [userId]); + + const refreshMessages = useCallback(async () => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + try { + const msgs = await store.getMessages(roomId); + setMessages(msgs); + } catch { /* non-fatal */ } + }, []); + + const refreshMembers = useCallback(async () => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + try { + const members = await store.getRoomMembers(roomId); + setCurrentRoomMembers(members); + } catch { /* non-fatal */ } + }, []); + + const refreshTyping = useCallback(() => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + const users = store.getTypingUsers(roomId, userId); + setTypingUsers(users); + }, [userId]); + + const refreshConnection = useCallback(() => { + const store = storeRef.current; + if (store) setConnectionLabel(store.getConnectionLabel()); + }, []); + + const handleStoreChange = useCallback( + (changes: Set) => { + if (changes.has("rooms") || changes.has("members")) refreshRooms(); + if (changes.has("messages")) refreshMessages(); + if (changes.has("members")) refreshMembers(); + if (changes.has("connection")) refreshConnection(); + if (changes.has("typing")) refreshTyping(); + }, + [refreshRooms, refreshMessages, refreshMembers, refreshConnection, refreshTyping], + ); + + // ── Initialization ────────────────────────────────────────────────────── + + useEffect(() => { + if (!applicationId || !userId || !userName) return; + + let cancelled = false; + const store = getChatStore(applicationId, wsUrl); + storeRef.current = store; + + (async () => { + try { + await store.init(); + if (cancelled) return; + + const rooms = await store.getUserRooms(userId); + if (cancelled) return; + + setUserRooms(rooms); + setConnectionLabel(store.getConnectionLabel()); + setReady(true); + } catch (e) { + if (!cancelled) + setError(e instanceof Error ? e.message : "Failed to initialize chat store"); + } + })(); + + const unsub = store.subscribe((changes) => { + if (!cancelled) handleStoreChange(changes); + }); + + return () => { + cancelled = true; + unsub(); + releaseChatStore(applicationId); + }; + }, [applicationId, userId, userName, wsUrl, handleStoreChange]); + + // ── LLM query invocation ───────────────────────────────────────────────── + + /** + * Fires the configured Lowcoder query for the current LLM room, passing: + * - prompt / message : the user's text (backward compat) + * - conversationHistory : [{role, content}] array for AI APIs + * - systemPrompt : the configured system prompt + * - roomId : so the query can segment by room if needed + */ + const invokeLlmQuery = useCallback( + async (queryName: string, userText: string, roomId: string): Promise => { + const store = storeRef.current; + const currentDispatch = dispatchRef.current; + + if (!currentDispatch) { + return "(LLM error: no dispatch available. Is the component configured?)"; + } + + // Build history before the message we just sent (inclusive of it) + const rawHistory = store ? await store.getLlmConversationHistory(roomId) : []; + + // Prepend system prompt if configured + const sysPrompt = systemPromptRef.current?.trim(); + const conversationHistory = sysPrompt + ? [{ role: "system" as const, content: sysPrompt }, ...rawHistory] + : rawHistory; + + try { + const result: any = await getPromiseAfterDispatch( + currentDispatch, + routeByNameAction( + queryName, + executeQueryAction({ + args: { + prompt: { value: userText }, + message: { value: userText }, + conversationHistory: { value: conversationHistory }, + systemPrompt: { value: sysPrompt ?? "" }, + roomId: { value: roomId }, + }, + }), + ), + ); + return extractAiText(result); + } catch (e: any) { + console.error("[LLM] Query error:", e); + throw new Error(e?.message || "LLM query failed"); + } + }, + [], + ); + + // ── Actions ────────────────────────────────────────────────────────────── + + /** + * Sends a user message. If the active room is an LLM room the sender's + * client also fires the configured query and writes the AI response to YJS, + * which syncs to all connected members automatically. + */ + const sendMessage = useCallback( + async (text: string): Promise => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + const room = currentRoomRef.current; + if (!store || !roomId || !text.trim()) return false; + + try { + // 1. Write user message (visible to everyone immediately via YJS) + await store.sendMessage(roomId, userId, userName, text.trim(), "user"); + + // 2. If LLM room — fire query and write AI response + if (room?.type === "llm" && room.llmQueryName) { + setIsLlmLoading(true); + try { + const aiText = await invokeLlmQuery(room.llmQueryName, text.trim(), roomId); + const botName = llmBotNameRef.current || "AI Assistant"; + await store.sendMessage(roomId, LLM_BOT_AUTHOR_ID, botName, aiText, "assistant"); + } catch (e: any) { + const botName = llmBotNameRef.current || "AI Assistant"; + await store.sendMessage( + roomId, + LLM_BOT_AUTHOR_ID, + botName, + `Sorry, I encountered an error: ${e?.message || "unknown"}`, + "assistant", + ); + } finally { + setIsLlmLoading(false); + } + } + + return true; + } catch { + return false; + } + }, + [userId, userName, invokeLlmQuery], + ); + + const switchRoom = useCallback(async (roomId: string) => { + const store = storeRef.current; + if (!store) return; + const room = await store.getRoom(roomId); + if (!room) return; + activeRoomIdRef.current = room.id; + currentRoomRef.current = room; + setCurrentRoom(room); + const [msgs, members] = await Promise.all([ + store.getMessages(room.id), + store.getRoomMembers(room.id), + ]); + setMessages(msgs); + setCurrentRoomMembers(members); + setIsLlmLoading(false); + }, []); + + const createRoom = useCallback( + async ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ): Promise => { + const store = storeRef.current; + if (!store) return null; + try { + return await store.createRoom(name, type, userId, userName, description, llmQueryName); + } catch { + return null; + } + }, + [userId, userName], + ); + + const joinRoom = useCallback( + async (roomId: string): Promise => { + const store = storeRef.current; + if (!store) return false; + try { + const ok = await store.joinRoom(roomId, userId, userName); + if (ok) await switchRoom(roomId); + return ok; + } catch { + return false; + } + }, + [userId, userName, switchRoom], + ); + + const leaveRoom = useCallback( + async (roomId: string): Promise => { + const store = storeRef.current; + if (!store) return false; + try { + const ok = await store.leaveRoom(roomId, userId); + if (ok && activeRoomIdRef.current === roomId) { + const rooms = await store.getUserRooms(userId); + if (rooms.length > 0) { + await switchRoom(rooms[0].id); + } else { + activeRoomIdRef.current = null; + currentRoomRef.current = null; + setCurrentRoom(null); + setMessages([]); + setCurrentRoomMembers([]); + setIsLlmLoading(false); + } + } + return ok; + } catch { + return false; + } + }, + [userId, switchRoom], + ); + + const searchRooms = useCallback( + async (query: string): Promise => { + const store = storeRef.current; + if (!store || !query.trim()) return []; + try { + return await store.getSearchableRooms(userId, query.trim()); + } catch { + return []; + } + }, + [userId], + ); + + const startTyping = useCallback(() => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + store.startTyping(roomId, userId, userName); + }, [userId, userName]); + + const stopTyping = useCallback(() => { + const store = storeRef.current; + const roomId = activeRoomIdRef.current; + if (!store || !roomId) return; + store.stopTyping(roomId, userId); + }, [userId]); + + return { + ready, + error, + connectionLabel, + isLlmLoading, + currentRoom, + messages, + userRooms, + currentRoomMembers, + typingUsers, + sendMessage, + switchRoom, + createRoom, + joinRoom, + leaveRoom, + searchRooms, + startTyping, + stopTyping, + }; +} 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..af90fbc9a --- /dev/null +++ b/client/packages/lowcoder/src/comps/hooks/chatControllerV2Comp.tsx @@ -0,0 +1,354 @@ +import React, { useEffect, useRef } from "react"; +import { Section, sectionNames } from "lowcoder-design"; +import { + simpleMultiComp, + stateComp, + withDefault, + withPropertyViewFn, + withViewFn, +} from "../generators"; +import { NameConfig, withExposingConfigs } from "../generators/withExposing"; +import { withMethodExposing } from "../generators/withMethodExposing"; +import { stringExposingStateControl } from "comps/controls/codeStateControl"; +import { StringControl } from "comps/controls/codeControl"; +import { eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { JSONObject } from "../../util/jsonTypes"; +import { useChatStore, UseChatStoreReturn } from "../comps/chatBoxComponentv2/useChatStore"; + +// ─── Event definitions ────────────────────────────────────────────────────── + +const ChatControllerEvents = [ + { label: "Message Sent", value: "messageSent", description: "Triggered when the current user sends a message" }, + { label: "Message Received", value: "messageReceived", description: "Triggered when a message is received from another user" }, + { label: "Room Joined", value: "roomJoined", description: "Triggered when the user joins a room" }, + { label: "Room Left", value: "roomLeft", description: "Triggered when the user leaves a room" }, + { label: "Connected", value: "connected", description: "Triggered when the chat store is ready" }, + { label: "Error", value: "error", description: "Triggered when an error occurs" }, +] as const; + +// ─── Children map ─────────────────────────────────────────────────────────── + +const childrenMap = { + // Configuration (shown in property panel, readable & writable) + applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), + userId: stringExposingStateControl("userId", "user_1"), + userName: stringExposingStateControl("userName", "User"), + defaultRoom: withDefault(StringControl, "general"), + wsUrl: withDefault(StringControl, "ws://localhost:3005"), + + // Events + onEvent: eventHandlerControl(ChatControllerEvents), + + // Reactive state (synced from useChatStore, exposed to users) + ready: stateComp(false), + error: stateComp(null), + connectionStatus: stateComp("Connecting..."), + currentRoom: stateComp(null), + messages: stateComp([]), + userRooms: stateComp([]), + currentRoomMembers: stateComp([]), + typingUsers: stateComp([]), + + // Internal: holds useChatStore actions so withMethodExposing can call them + _chatActions: stateComp({}), +}; + +// ─── View function (headless — returns null) ──────────────────────────────── + +const ChatControllerV2Base = withViewFn( + simpleMultiComp(childrenMap), + (comp) => { + const userId = comp.children.userId.getView().value; + const userName = comp.children.userName.getView().value; + const applicationId = comp.children.applicationId.getView().value; + const defaultRoom = comp.children.defaultRoom.getView(); + const wsUrl = comp.children.wsUrl.getView(); + + const chat = useChatStore({ + applicationId: applicationId || "lowcoder_app", + defaultRoom: defaultRoom || "general", + userId: userId || "user_1", + userName: userName || "User", + wsUrl: wsUrl || "ws://localhost:3005", + }); + + const prevRef = useRef<{ + ready: boolean; + msgCount: number; + roomId: string | null; + }>({ ready: false, msgCount: 0, roomId: null }); + + const triggerEvent = comp.children.onEvent.getView(); + + // ── Sync ready ───────────────────────────────────────────────────── + useEffect(() => { + comp.children.ready.dispatchChangeValueAction(chat.ready); + if (chat.ready && !prevRef.current.ready) { + triggerEvent("connected"); + } + prevRef.current.ready = chat.ready; + }, [chat.ready]); + + // ── Sync error ───────────────────────────────────────────────────── + useEffect(() => { + comp.children.error.dispatchChangeValueAction(chat.error); + if (chat.error) { + triggerEvent("error"); + } + }, [chat.error]); + + // ── Sync connection status ───────────────────────────────────────── + useEffect(() => { + comp.children.connectionStatus.dispatchChangeValueAction(chat.connectionLabel); + }, [chat.connectionLabel]); + + // ── Sync currentRoom ─────────────────────────────────────────────── + useEffect(() => { + comp.children.currentRoom.dispatchChangeValueAction( + chat.currentRoom as unknown as JSONObject | null, + ); + const newRoomId = chat.currentRoom?.id ?? null; + if (newRoomId && newRoomId !== prevRef.current.roomId) { + triggerEvent("roomJoined"); + } + prevRef.current.roomId = newRoomId; + }, [chat.currentRoom]); + + // ── Sync messages ────────────────────────────────────────────────── + useEffect(() => { + comp.children.messages.dispatchChangeValueAction( + chat.messages as unknown as JSONObject[], + ); + const newCount = chat.messages.length; + if (newCount > prevRef.current.msgCount && prevRef.current.msgCount > 0) { + const lastMsg = chat.messages[newCount - 1]; + if (lastMsg?.authorId === userId) { + triggerEvent("messageSent"); + } else { + triggerEvent("messageReceived"); + } + } + prevRef.current.msgCount = newCount; + }, [chat.messages, userId]); + + // ── Sync userRooms ───────────────────────────────────────────────── + useEffect(() => { + comp.children.userRooms.dispatchChangeValueAction( + chat.userRooms as unknown as JSONObject[], + ); + }, [chat.userRooms]); + + // ── Sync currentRoomMembers ──────────────────────────────────────── + useEffect(() => { + comp.children.currentRoomMembers.dispatchChangeValueAction( + chat.currentRoomMembers as unknown as JSONObject[], + ); + }, [chat.currentRoomMembers]); + + // ── Sync typingUsers ─────────────────────────────────────────────── + useEffect(() => { + comp.children.typingUsers.dispatchChangeValueAction( + chat.typingUsers as unknown as JSONObject[], + ); + }, [chat.typingUsers]); + + // ── Store actions for method access ──────────────────────────────── + useEffect(() => { + comp.children._chatActions.dispatchChangeValueAction( + chat as unknown as JSONObject, + ); + }, [chat.ready, chat.currentRoom]); + + return null; + }, +); + +// ─── Property panel ───────────────────────────────────────────────────────── + +const ChatControllerV2WithProps = withPropertyViewFn(ChatControllerV2Base, (comp) => ( + <> +
+ {comp.children.applicationId.propertyView({ + label: "Application ID", + tooltip: "Scopes chat rooms to this application", + })} + {comp.children.userId.propertyView({ + label: "User ID", + tooltip: "Current user's unique identifier", + })} + {comp.children.userName.propertyView({ + label: "User Name", + tooltip: "Current user's display name", + })} + {comp.children.defaultRoom.propertyView({ + label: "Default Room", + tooltip: "Room to auto-join on initialization", + })} + {comp.children.wsUrl.propertyView({ + label: "WebSocket URL", + tooltip: "Yjs WebSocket server URL for real-time sync", + })} +
+
+ {comp.children.onEvent.getPropertyView()} +
+ +)); + +// ─── Expose state properties ──────────────────────────────────────────────── + +let ChatControllerV2Comp = withExposingConfigs(ChatControllerV2WithProps, [ + new NameConfig("ready", "Whether the chat store is initialized and ready"), + new NameConfig("error", "Error message if initialization failed"), + new NameConfig("connectionStatus", "Current connection status label"), + new NameConfig("currentRoom", "Currently active chat room object"), + new NameConfig("messages", "Messages in the current room"), + new NameConfig("userRooms", "Rooms the current user has joined"), + new NameConfig("currentRoomMembers", "Members of the current room"), + new NameConfig("typingUsers", "Users currently typing in the current room"), + new NameConfig("userId", "Current user ID"), + new NameConfig("userName", "Current user name"), + new NameConfig("applicationId", "Application scope ID"), +]); + +// ─── Expose methods ───────────────────────────────────────────────────────── + +ChatControllerV2Comp = withMethodExposing(ChatControllerV2Comp, [ + { + method: { + name: "sendMessage", + description: "Send a message to the current room", + params: [{ name: "text", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.sendMessage) { + return await actions.sendMessage(values?.[0] as string); + } + return false; + }, + }, + { + method: { + name: "switchRoom", + description: "Switch to a different room by its ID", + params: [{ name: "roomId", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.switchRoom) { + await actions.switchRoom(values?.[0] as string); + } + }, + }, + { + method: { + name: "createRoom", + description: "Create a new chat room", + params: [ + { name: "name", type: "string" }, + { name: "type", type: "string" }, + { name: "description", type: "string" }, + ], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.createRoom) { + return await actions.createRoom( + values?.[0] as string, + (values?.[1] as "public" | "private") || "public", + values?.[2] as string | undefined, + ); + } + return null; + }, + }, + { + method: { + name: "joinRoom", + description: "Join a room by its ID", + params: [{ name: "roomId", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.joinRoom) { + return await actions.joinRoom(values?.[0] as string); + } + return false; + }, + }, + { + method: { + name: "leaveRoom", + description: "Leave a room by its ID", + params: [{ name: "roomId", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.leaveRoom) { + const ok = await actions.leaveRoom(values?.[0] as string); + if (ok) { + comp.children.onEvent.getView()("roomLeft"); + } + return ok; + } + return false; + }, + }, + { + method: { + name: "searchRooms", + description: "Search for public rooms by query string", + params: [{ name: "query", type: "string" }], + }, + execute: async (comp, values) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.searchRooms) { + return await actions.searchRooms(values?.[0] as string); + } + return []; + }, + }, + { + method: { + name: "startTyping", + description: "Signal that the current user started typing", + params: [], + }, + execute: (comp) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.startTyping) { + actions.startTyping(); + } + }, + }, + { + method: { + name: "stopTyping", + description: "Signal that the current user stopped typing", + params: [], + }, + execute: (comp) => { + const actions = comp.children._chatActions.getView() as unknown as UseChatStoreReturn; + if (actions?.stopTyping) { + actions.stopTyping(); + } + }, + }, + { + method: { + name: "setUser", + description: "Update the current chat user credentials", + params: [ + { name: "userId", type: "string" }, + { name: "userName", type: "string" }, + ], + }, + execute: (comp, values) => { + if (values?.[0]) comp.children.userId.getView().onChange(values[0] as string); + if (values?.[1]) comp.children.userName.getView().onChange(values[1] as string); + }, + }, +]); + +export { ChatControllerV2Comp }; diff --git a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx index fa4294709..6e6e5f19a 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx @@ -38,6 +38,7 @@ import UrlParamsHookComp from "./UrlParamsHookComp"; import { UtilsComp } from "./utilsComp"; import { ScreenInfoHookComp } from "./screenInfoComp"; import { ChatControllerComp } from "../comps/chatBoxComponent/chatControllerComp"; +import { ChatControllerV2Comp } from "./chatControllerV2Comp"; window._ = _; window.dayjs = dayjs; @@ -120,6 +121,7 @@ const HookMap: HookCompMapRawType = { drawer: DrawerComp, theme: ThemeComp, chatController: ChatControllerComp, + chatControllerV2: ChatControllerV2Comp, }; export const HookTmpComp = withTypeAndChildren(HookMap, "title", { diff --git a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx index 22e79e6d1..ee63a7f6a 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookCompTypes.tsx @@ -19,7 +19,8 @@ const AllHookComp = [ "urlParams", "theme", "meeting", - "chatController" + "chatController", + "chatControllerV2" ] as const; export type HookCompType = (typeof AllHookComp)[number]; @@ -54,6 +55,10 @@ const HookCompConfig: Record< category: "ui", singleton: false, }, + chatControllerV2: { + category: "ui", + singleton: false, + }, lodashJsLib: { category: "hide", }, diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index be34b1670..ebbb019ad 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 { ChatControllerV2Comp } from "./hooks/chatControllerV2Comp"; +import { ChatBoxV2Comp } from "./comps/chatBoxComponentv2"; type Registry = { [key in UICompType]?: UICompManifest; @@ -973,6 +975,30 @@ export var uiCompMap: Registry = { isContainer: true, }, + chatControllerV2: { + name: "Chat Controller V2", + enName: "Chat Controller V2", + description: "Headless chat controller — exposes state, methods & events so you can build custom chat UIs with built-in components", + categories: ["collaboration"], + icon: CommentCompIcon, + keywords: "chatbox,chat,controller,headless,rooms,messaging,v2", + comp: ChatControllerV2Comp, + }, + + chatBoxV: { + name: "Chat Box V2", + enName: "Chat Box V2", + description: "Chat Box with rooms, messaging, and local persistence", + categories: ["collaboration"], + icon: CommentCompIcon, + keywords: "chatbox,chat,conversation,rooms,messaging,v2", + comp: ChatBoxV2Comp, + layoutInfo: { + w: 12, + h: 24, + }, + }, + // Forms form: { diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index 1de611df8..35f80bbb8 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" + | "chatControllerV2" + | "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..97a29ad78 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: , + chatControllerV2: , + chatBoxV: , } as const;