diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index 04a4c30535..323a2a7b7f 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -33,6 +33,8 @@ "@jsonforms/core": "^3.5.1", "@lottiefiles/dotlottie-react": "^0.13.0", "@manaflair/redux-batch": "^1.0.0", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", "@rjsf/antd": "^5.24.9", diff --git a/client/packages/lowcoder/src/components/ResCreatePanel.tsx b/client/packages/lowcoder/src/components/ResCreatePanel.tsx index 04ed9fb79b..e52ea93df0 100644 --- a/client/packages/lowcoder/src/components/ResCreatePanel.tsx +++ b/client/packages/lowcoder/src/components/ResCreatePanel.tsx @@ -13,7 +13,7 @@ import { BottomResTypeEnum } from "types/bottomRes"; import { LargeBottomResIconWrapper } from "util/bottomResUtils"; import type { PageType } from "../constants/pageConstants"; import type { SizeType } from "antd/es/config-provider/SizeContext"; -import { Datasource } from "constants/datasourceConstants"; +import { Datasource, QUICK_SSE_HTTP_API_ID } from "constants/datasourceConstants"; import { QUICK_GRAPHQL_ID, QUICK_REST_API_ID, @@ -172,6 +172,7 @@ const ResButton = (props: { compType: "streamApi", }, }, + alasql: { label: trans("query.quickAlasql"), type: BottomResTypeEnum.Query, @@ -179,6 +180,14 @@ const ResButton = (props: { compType: "alasql", }, }, + sseHttpApi: { + label: trans("query.quickSseHttpAPI"), + type: BottomResTypeEnum.Query, + extra: { + compType: "sseHttpApi", + dataSourceId: QUICK_SSE_HTTP_API_ID, + }, + }, graphql: { label: trans("query.quickGraphql"), type: BottomResTypeEnum.Query, @@ -339,6 +348,7 @@ export function ResCreatePanel(props: ResCreateModalProps) { + setCurlModalVisible(true)}> diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index d26dce7b29..0091ed6ab4 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -1,46 +1,258 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx -import { UICompBuilder } from "comps/generators"; -import { NameConfig, withExposingConfigs } from "comps/generators/withExposing"; -import { chatChildrenMap } from "./chatCompTypes"; -import { ChatView } from "./chatView"; -import { ChatPropertyView } from "./chatPropertyView"; -import { useEffect, useState } from "react"; -import { changeChildAction } from "lowcoder-core"; - -// Build the component -let ChatTmpComp = new UICompBuilder( - chatChildrenMap, - (props, dispatch) => { - useEffect(() => { - if (Boolean(props.tableName)) return; - - // Generate a unique database name for this ChatApp instance - const generateUniqueTableName = () => { - const timestamp = Date.now(); - const randomId = Math.random().toString(36).substring(2, 15); - return `TABLE_${timestamp}`; - }; - - const tableName = generateUniqueTableName(); - dispatch(changeChildAction('tableName', tableName, true)); - }, [props.tableName]); - - if (!props.tableName) { - return null; // Don't render until we have a unique DB name - } - return ; - } -) - .setPropertyViewFn((children) => ) - .build(); - -ChatTmpComp = class extends ChatTmpComp { - override autoHeight(): boolean { - return this.children.autoHeight.getView(); - } -}; - -// Export the component -export const ChatComp = withExposingConfigs(ChatTmpComp, [ - new NameConfig("text", "Chat component text"), +// client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx + +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 { 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 { ChatPropertyView } from "./chatPropertyView"; +import { createChatStorage } from "./utils/storageFactory"; +import { QueryHandler, createMessageHandler } from "./handlers/messageHandlers"; +import { useMemo, useRef, useEffect } from "react"; +import { changeChildAction } from "lowcoder-core"; +import { ChatMessage } from "./types/chatTypes"; +import { trans } from "i18n"; + +import "@assistant-ui/styles/index.css"; +import "@assistant-ui/styles/markdown.css"; + +// ============================================================================ +// CHAT-SPECIFIC EVENTS +// ============================================================================ + +export const componentLoadEvent: EventConfigType = { + label: trans("chat.componentLoad"), + value: "componentLoad", + description: trans("chat.componentLoadDesc"), +}; + +export const messageSentEvent: EventConfigType = { + label: trans("chat.messageSent"), + value: "messageSent", + description: trans("chat.messageSentDesc"), +}; + +export const messageReceivedEvent: EventConfigType = { + label: trans("chat.messageReceived"), + value: "messageReceived", + description: trans("chat.messageReceivedDesc"), +}; + +export const threadCreatedEvent: EventConfigType = { + label: trans("chat.threadCreated"), + value: "threadCreated", + description: trans("chat.threadCreatedDesc"), +}; + +export const threadUpdatedEvent: EventConfigType = { + label: trans("chat.threadUpdated"), + value: "threadUpdated", + description: trans("chat.threadUpdatedDesc"), +}; + +export const threadDeletedEvent: EventConfigType = { + label: trans("chat.threadDeleted"), + value: "threadDeleted", + description: trans("chat.threadDeletedDesc"), +}; + +const ChatEventOptions = [ + componentLoadEvent, + messageSentEvent, + messageReceivedEvent, + threadCreatedEvent, + threadUpdatedEvent, + threadDeletedEvent, +] as const; + +export const ChatEventHandlerControl = eventHandlerControl(ChatEventOptions); + +// ============================================================================ +// SIMPLIFIED CHILDREN MAP - WITH EVENT HANDLERS +// ============================================================================ + + +export function addSystemPromptToHistory( + conversationHistory: ChatMessage[], + systemPrompt: string +): Array<{ role: string; content: string; timestamp: number }> { + // Format conversation history for use in queries + const formattedHistory = conversationHistory.map(msg => ({ + role: msg.role, + content: msg.text, + timestamp: msg.timestamp + })); + + // Create system message (always exists since we have default) + const systemMessage = [{ + role: "system" as const, + content: systemPrompt, + timestamp: Date.now() - 1000000 // Ensure it's always first chronologically + }]; + + // Return complete history with system prompt prepended + return [...systemMessage, ...formattedHistory]; +} + + +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) + _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 + systemPrompt: withDefault(StringControl, trans("chat.defaultSystemPrompt")), + streaming: BoolControl.DEFAULT_TRUE, + + // UI Configuration + placeholder: withDefault(StringControl, trans("chat.defaultPlaceholder")), + + // Database Information (read-only) + databaseName: withDefault(StringControl, ""), + + // Event Handlers + onEvent: ChatEventHandlerControl, + + // Exposed Variables (not shown in Property View) + currentMessage: stringExposingStateControl("currentMessage", ""), + conversationHistory: stringExposingStateControl("conversationHistory", "[]"), +}; + +// ============================================================================ +// CLEAN CHATCOMP - USES NEW ARCHITECTURE +// ============================================================================ + +const ChatTmpComp = new UICompBuilder( + chatChildrenMap, + (props, dispatch) => { + + const uniqueTableName = useRef(); + // Generate unique table name once (with persistence) + if (!uniqueTableName.current) { + // Use persisted name if exists, otherwise generate new one + uniqueTableName.current = props._internalDbName || generateUniqueTableName(); + + // Save the name for future refreshes + if (!props._internalDbName) { + dispatch(changeChildAction("_internalDbName", uniqueTableName.current, false)); + } + + // Update the database name in the props for display + const dbName = `ChatDB_${uniqueTableName.current}`; + dispatch(changeChildAction("databaseName", dbName, false)); + } + // Create storage with unique table name + const storage = useMemo(() => + createChatStorage(uniqueTableName.current!), + [] + ); + + // Create message handler based on type + 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 + }); + } + }, [ + props.handlerType, + props.chatQuery, + props.modelHost, + props.systemPrompt, + props.streaming, + dispatch, + ]); + + // Handle message updates for exposed variable + const handleMessageUpdate = (message: string) => { + dispatch(changeChildAction("currentMessage", message, false)); + // 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"); + } +}; + + // Cleanup on unmount + useEffect(() => { + return () => { + const tableName = uniqueTableName.current; + if (tableName) { + storage.cleanup(); + } + }; + }, []); + + return ( + + ); + } +) +.setPropertyViewFn((children) => ) +.build(); + +// ============================================================================ +// EXPORT WITH EXPOSED VARIABLES +// ============================================================================ + +export const ChatComp = withExposingConfigs(ChatTmpComp, [ + new NameConfig("currentMessage", "Current user message"), + new NameConfig("conversationHistory", "Full conversation history as JSON array (includes system prompt for API calls)"), + 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 87dca43a37..3151bff6ad 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts @@ -1,39 +1,26 @@ // client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts -import { StringControl, NumberControl } from "comps/controls/codeControl"; -import { withDefault } from "comps/generators"; -import { BoolControl } from "comps/controls/boolControl"; -import { dropdownControl } from "comps/controls/dropdownControl"; -import QuerySelectControl from "comps/controls/querySelectControl"; -import { AutoHeightControl } from "@lowcoder-ee/comps/controls/autoHeightControl"; -// Model type dropdown options -const ModelTypeOptions = [ - { label: "Direct LLM", value: "direct-llm" }, - { label: "n8n Workflow", value: "n8n" }, -] as const; +// ============================================================================ +// CLEAN CHATCOMP TYPES - SIMPLIFIED AND FOCUSED +// ============================================================================ -export const chatChildrenMap = { - text: withDefault(StringControl, "Chat Component Placeholder"), - modelType: dropdownControl(ModelTypeOptions, "direct-llm"), - modelHost: withDefault(StringControl, ""), - streaming: BoolControl.DEFAULT_TRUE, - systemPrompt: withDefault(StringControl, "You are a helpful assistant."), - agent: BoolControl, - maxInteractions: withDefault(NumberControl, 10), - chatQuery: QuerySelectControl, - autoHeight: AutoHeightControl, - tableName: withDefault(StringControl, ""), +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" + systemPrompt: string; + streaming: boolean; + + // UI + placeholder: string; + + // Exposed Variables + currentMessage: string; // Read-only exposed variable }; -export type ChatCompProps = { - text?: string; - chatQuery?: string; - modelType?: string; - streaming?: boolean; - systemPrompt?: string; - agent?: boolean; - maxInteractions?: number; - modelHost?: string; - autoHeight?: boolean; - tableName?: string; -}; \ No newline at end of file +// Legacy export for backwards compatibility (if needed) +export type ChatCompLegacyProps = ChatCompProps; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx index 2a9143c4ae..0e2fd02901 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx @@ -1,35 +1,91 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx -import React from "react"; -import { Section, sectionNames } from "lowcoder-design"; -import { trans } from "i18n"; - -export const ChatPropertyView = React.memo((props: any) => { - const { children } = props; - - return ( - <> -
- {children.modelType.propertyView({ label: "Model Type" })} - {children.modelHost.propertyView({ label: "Model Host" })} - {/* {children.text.propertyView({ label: "Text" })} - {children.chatQuery.propertyView({ label: "Chat Query" })} */} - {children.streaming.propertyView({ label: "Enable Streaming" })} - {children.systemPrompt.propertyView({ - label: "System Prompt", - placeholder: "Enter system prompt...", - enableSpellCheck: false, - })} - {children.agent.propertyView({ label: "Enable Agent Mode" })} - {children.maxInteractions.propertyView({ - label: "Max Interactions", - placeholder: "10", - })} -
-
- {children.autoHeight.propertyView({ label: trans("prop.height") })} -
- - ); -}); - +// client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx + +import React, { useMemo } from "react"; +import { Section, sectionNames, DocLink } from "lowcoder-design"; +import { placeholderPropertyView } from "../../utils/propertyUtils"; +import { trans } from "i18n"; + +// ============================================================================ +// CLEAN PROPERTY VIEW - FOCUSED ON ESSENTIAL CONFIGURATION +// ============================================================================ + +export const ChatPropertyView = React.memo((props: any) => { + const { children } = props; + + return useMemo(() => ( + <> + {/* Help & Documentation - Outside of Section */} +
+ + 📖 View Documentation + +
+ + {/* Message Handler Configuration */} +
+ {children.handlerType.propertyView({ + label: trans("chat.handlerType"), + tooltip: trans("chat.handlerTypeTooltip"), + })} + + {/* 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"), + })} +
+ + {/* Database Section */} +
+ {children.databaseName.propertyView({ + label: trans("chat.databaseName"), + tooltip: trans("chat.databaseNameTooltip"), + readonly: true + })} +
+ + {/* STANDARD EVENT HANDLERS SECTION */} +
+ {children.onEvent.getPropertyView()} +
+ + + ), [children]); +}); + ChatPropertyView.displayName = 'ChatPropertyView'; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatView.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatView.tsx deleted file mode 100644 index eca764ba6a..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatView.tsx +++ /dev/null @@ -1,13 +0,0 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/chatView.tsx -import React from "react"; -import { ChatCompProps } from "./chatCompTypes"; -import { ChatApp } from "./components/ChatApp"; - -import "@assistant-ui/styles/index.css"; -import "@assistant-ui/styles/markdown.css"; - -export const ChatView = React.memo((props: ChatCompProps) => { - return ; -}); - -ChatView.displayName = 'ChatView'; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatApp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatApp.tsx deleted file mode 100644 index e8092a494b..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatApp.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { ChatProvider } from "./context/ChatContext"; -import { ChatMain } from "./ChatMain"; -import { ChatCompProps } from "../chatCompTypes"; -import { useEffect, useState } from "react"; - -export function ChatApp(props: ChatCompProps) { - if (!Boolean(props.tableName)) { - return null; // Don't render until we have a unique DB name - } - - return ( - - - - ); -} diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx new file mode 100644 index 0000000000..af867b7f5b --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx @@ -0,0 +1,31 @@ +// 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"; + +// ============================================================================ +// 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/ChatMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx similarity index 50% rename from client/packages/lowcoder/src/comps/comps/chatComp/components/ChatMain.tsx rename to client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx index 1c906e4081..4bc7363b9a 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx @@ -1,338 +1,270 @@ -import React, { useContext, useState, useRef, useEffect } from "react"; -import { - useExternalStoreRuntime, - ThreadMessageLike, - AppendMessage, - AssistantRuntimeProvider, - ExternalStoreThreadListAdapter, -} from "@assistant-ui/react"; -import { Thread } from "./assistant-ui/thread"; -import { ThreadList } from "./assistant-ui/thread-list"; -import { - useChatContext, - MyMessage, - ThreadData, - RegularThreadData, - ArchivedThreadData -} from "./context/ChatContext"; -import styled from "styled-components"; -import { ChatCompProps } from "../chatCompTypes"; -import { message } from "antd"; -import { EditorContext } from "@lowcoder-ee/comps/editorState"; -import { addComponentAction, nestComponentAction } from "../../preLoadComp/actions/componentManagement"; -import { configureComponentAction } from "../../preLoadComp/actions/componentConfiguration"; - -const ChatContainer = styled.div<{ $autoHeight?: boolean }>` - display: flex; - height: ${props => props.$autoHeight ? '500px' : '100%'}; - - 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; - } - } -`; - -const generateId = () => Math.random().toString(36).substr(2, 9); - -const callYourAPI = async (params: { - text: string, - modelHost: string, - modelType: string, - sessionId: string, -}) => { - const { text, modelHost, modelType, sessionId } = params; - - let url = modelHost; - if (modelType === "direct-llm") { - url = `${modelHost}/api/chat/completions`; - } - - const response = await fetch(`${url}`, { - method: "POST", - body: JSON.stringify({ - text, - sessionId, - }), - }); - - return response.json(); - // Simulate API delay - // await new Promise(resolve => setTimeout(resolve, 1500)); - - // Simple responses - // return { - // content: "This is a mock response from your backend. You typed: " + text - // }; -}; - -export function ChatMain(props: ChatCompProps) { - const { state, actions } = useChatContext(); - const [isRunning, setIsRunning] = useState(false); - const editorState = useContext(EditorContext); - const editorStateRef = useRef(editorState); - - // Keep the ref updated with the latest editorState - useEffect(() => { - // console.log("EDITOR STATE CHANGE ---> ", editorState); - editorStateRef.current = editorState; - }, [editorState]); - - console.log("STATE", state); - - // Get messages for current thread - const currentMessages = actions.getCurrentMessages(); - - // Convert custom format to ThreadMessageLike - const convertMessage = (message: MyMessage): ThreadMessageLike => ({ - role: message.role, - content: [{ type: "text", text: message.text }], - id: message.id, - createdAt: new Date(message.timestamp), - }); - - const performAction = async (actions: any[]) => { - const comp = editorStateRef.current.getUIComp().children.comp; - if (!comp) { - console.error("No comp found"); - return; - } - // const layout = comp.children.layout.getView(); - // console.log("LAYOUT", layout); - - for (const actionItem of actions) { - const { action, component, ...action_payload } = actionItem; - - switch (action) { - case "place_component": - await addComponentAction.execute({ - actionKey: action, - actionValue: "", - actionPayload: action_payload, - selectedComponent: component, - selectedEditorComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current - }); - break; - case "nest_component": - await nestComponentAction.execute({ - actionKey: action, - actionValue: "", - actionPayload: action_payload, - selectedComponent: component, - selectedEditorComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current - }); - break; - case "set_properties": - debugger; - await configureComponentAction.execute({ - actionKey: action, - actionValue: component, - actionPayload: action_payload, - selectedEditorComponent: null, - selectedComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current - }); - break; - default: - break; - } - await new Promise(resolve => setTimeout(resolve, 1000)); - } - }; - - const onNew = async (message: AppendMessage) => { - // Extract text from AppendMessage content array - if (message.content.length !== 1 || message.content[0]?.type !== "text") { - throw new Error("Only text content is supported"); - } - - // Add user message in custom format - const userMessage: MyMessage = { - id: generateId(), - role: "user", - text: message.content[0].text, - timestamp: Date.now(), - }; - - // Update current thread with new user message - await actions.addMessage(state.currentThreadId, userMessage); - setIsRunning(true); - - try { - // Call mock API - const response = await callYourAPI({ - text: userMessage.text, - modelHost: props.modelHost!, - modelType: props.modelType!, - sessionId: state.currentThreadId, - }); - const {explanation: reply, actions: editorActions} = JSON.parse(response?.output); - performAction(editorActions); - - const assistantMessage: MyMessage = { - id: generateId(), - role: "assistant", - text: reply, - timestamp: Date.now(), - }; - - // Update current thread with assistant response - await actions.addMessage(state.currentThreadId, assistantMessage); - } catch (error) { - // Handle errors gracefully - const errorMessage: MyMessage = { - id: generateId(), - role: "assistant", - text: `Sorry, I encountered an error: ${error instanceof Error ? error.message : 'Unknown error'}`, - timestamp: Date.now(), - }; - - await actions.addMessage(state.currentThreadId, errorMessage); - } finally { - setIsRunning(false); - } - }; - - // Add onEdit functionality - const onEdit = async (message: AppendMessage) => { - // Extract text from AppendMessage content array - if (message.content.length !== 1 || message.content[0]?.type !== "text") { - throw new Error("Only text content is supported"); - } - - // Find the index where to insert the edited message - const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; - - // Keep messages up to the parent - const newMessages = [...currentMessages.slice(0, index)]; - - // Add the edited message in custom format - const editedMessage: MyMessage = { - id: generateId(), - role: "user", - text: message.content[0].text, - timestamp: Date.now(), - }; - newMessages.push(editedMessage); - - // Update messages using the new context action - await actions.updateMessages(state.currentThreadId, newMessages); - setIsRunning(true); - - try { - // Generate new response - const response = await callYourAPI({ - text: editedMessage.text, - modelHost: props.modelHost!, - modelType: props.modelType!, - sessionId: state.currentThreadId, - }); - - const {explanation: reply, actions: editorActions} = JSON.parse(response?.output); - performAction(editorActions); - - const assistantMessage: MyMessage = { - id: generateId(), - role: "assistant", - text: reply, - timestamp: Date.now(), - }; - - newMessages.push(assistantMessage); - await actions.updateMessages(state.currentThreadId, newMessages); - } catch (error) { - // Handle errors gracefully - const errorMessage: MyMessage = { - id: generateId(), - role: "assistant", - text: `Sorry, I encountered an error: ${error instanceof Error ? error.message : 'Unknown error'}`, - timestamp: Date.now(), - }; - - newMessages.push(errorMessage); - await actions.updateMessages(state.currentThreadId, newMessages); - } finally { - setIsRunning(false); - } - }; - - // Thread list adapter for managing multiple threads - 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("New Chat"); - 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, - }, - }); - - if (!state.isInitialized) { - return
Loading...
; - } - - return ( - - - - - - - ); -} - +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx + +import React, { useState, useEffect } from "react"; +import { + useExternalStoreRuntime, + ThreadMessageLike, + AppendMessage, + AssistantRuntimeProvider, + ExternalStoreThreadListAdapter, +} from "@assistant-ui/react"; +import { Thread } from "./assistant-ui/thread"; +import { ThreadList } from "./assistant-ui/thread-list"; +import { + useChatContext, + ChatMessage, + RegularThreadData, + ArchivedThreadData +} from "./context/ChatContext"; +import { MessageHandler } from "../types/chatTypes"; +import styled from "styled-components"; +import { trans } from "i18n"; + +// ============================================================================ +// STYLED COMPONENTS (same as your current ChatMain) +// ============================================================================ + +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) { + const { state, actions } = useChatContext(); + const [isRunning, setIsRunning] = useState(false); + + // Get messages for current thread + const currentMessages = actions.getCurrentMessages(); + + // Notify parent component of conversation changes + useEffect(() => { + onConversationUpdate?.(currentMessages); + }, [currentMessages]); + + // Trigger component load event on mount + useEffect(() => { + onEvent?.("componentLoad"); + }, [onEvent]); + + // Convert custom format to ThreadMessageLike (same as your current implementation) + const convertMessage = (message: ChatMessage): ThreadMessageLike => ({ + role: message.role, + content: [{ type: "text", text: message.text }], + id: message.id, + createdAt: new Date(message.timestamp), + }); + + // Handle new message - MUCH CLEANER with messageHandler + const onNew = async (message: AppendMessage) => { + // Extract text from AppendMessage content array + if (message.content.length !== 1 || message.content[0]?.type !== "text") { + throw new Error("Only text content is supported"); + } + + // Add user message in custom format + const userMessage: ChatMessage = { + id: generateId(), + role: "user", + text: message.content[0].text, + timestamp: Date.now(), + }; + + // Update currentMessage state to expose to queries + onMessageUpdate?.(userMessage.text); + + // Update current thread with new user message + await actions.addMessage(state.currentThreadId, userMessage); + setIsRunning(true); + + try { + + // Use the message handler (no more complex logic here!) + const response = await messageHandler.sendMessage(userMessage.text); + + const assistantMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }; + + // Update current thread with assistant response + await actions.addMessage(state.currentThreadId, assistantMessage); + } catch (error) { + // Handle errors gracefully + const errorMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }; + + await actions.addMessage(state.currentThreadId, errorMessage); + } finally { + setIsRunning(false); + } + }; + + // Handle edit message - CLEANER with messageHandler + const onEdit = async (message: AppendMessage) => { + // Extract text from AppendMessage content array + if (message.content.length !== 1 || message.content[0]?.type !== "text") { + throw new Error("Only text content is supported"); + } + + // Find the index where to insert the edited message + const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; + + // Keep messages up to the parent + const newMessages = [...currentMessages.slice(0, index)]; + + // Add the edited message in custom format + const editedMessage: ChatMessage = { + id: generateId(), + role: "user", + text: message.content[0].text, + timestamp: Date.now(), + }; + newMessages.push(editedMessage); + + // Update currentMessage state to expose to queries + onMessageUpdate?.(editedMessage.text); + + // Update messages using the new context action + await actions.updateMessages(state.currentThreadId, newMessages); + setIsRunning(true); + + try { + // Use the message handler (clean!) + const response = await messageHandler.sendMessage(editedMessage.text); + + const assistantMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }; + + newMessages.push(assistantMessage); + await actions.updateMessages(state.currentThreadId, newMessages); + } catch (error) { + // Handle errors gracefully + const errorMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }; + + newMessages.push(errorMessage); + await actions.updateMessages(state.currentThreadId, newMessages); + } finally { + setIsRunning(false); + } + }; + + // Thread list adapter for managing multiple threads (same as your current implementation) + const threadListAdapter: ExternalStoreThreadListAdapter = { + threadId: state.currentThreadId, + threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"), + archivedThreads: state.threadList.filter((t): t is ArchivedThreadData => t.status === "archived"), + + onSwitchToNewThread: async () => { + const threadId = await actions.createThread(trans("chat.newChatTitle")); + actions.setCurrentThread(threadId); + onEvent?.("threadCreated"); + }, + + onSwitchToThread: (threadId) => { + actions.setCurrentThread(threadId); + }, + + onRename: async (threadId, newTitle) => { + await actions.updateThread(threadId, { title: newTitle }); + onEvent?.("threadUpdated"); + }, + + onArchive: async (threadId) => { + await actions.updateThread(threadId, { status: "archived" }); + onEvent?.("threadUpdated"); + }, + + onDelete: async (threadId) => { + await actions.deleteThread(threadId); + onEvent?.("threadDeleted"); + }, + }; + + const runtime = useExternalStoreRuntime({ + messages: currentMessages, + setMessages: (messages) => { + actions.updateMessages(state.currentThreadId, messages); + }, + convertMessage, + isRunning, + onNew, + onEdit, + adapters: { + threadList: threadListAdapter, + }, + }); + + if (!state.isInitialized) { + return
Loading...
; + } + + return ( + + + + + + + ); +} diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx new file mode 100644 index 0000000000..530c3fce31 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx @@ -0,0 +1,48 @@ +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx + +import React, { useMemo } from "react"; +import { ChatCore } from "./ChatCore"; +import { createChatStorage } from "../utils/storageFactory"; +import { N8NHandler } from "../handlers/messageHandlers"; +import { ChatPanelProps } from "../types/chatTypes"; +import { trans } from "i18n"; + +import "@assistant-ui/styles/index.css"; +import "@assistant-ui/styles/markdown.css"; + +// ============================================================================ +// CHAT PANEL - CLEAN BOTTOM PANEL COMPONENT +// ============================================================================ + +export function ChatPanel({ + tableName, + modelHost, + systemPrompt = trans("chat.defaultSystemPrompt"), + streaming = true, + onMessageUpdate +}: ChatPanelProps) { + + // Create storage instance + const storage = useMemo(() => + createChatStorage(tableName), + [tableName] + ); + + // Create N8N message handler + const messageHandler = useMemo(() => + new N8NHandler({ + modelHost, + systemPrompt, + streaming + }), + [modelHost, systemPrompt, streaming] + ); + + return ( + + ); +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread-list.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread-list.tsx index 54dcbc5089..46bf98eed4 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread-list.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread-list.tsx @@ -1,16 +1,17 @@ import type { FC } from "react"; +import { useState } from "react"; import { ThreadListItemPrimitive, ThreadListPrimitive, + useThreadListItem, } from "@assistant-ui/react"; import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"; - import { TooltipIconButton } from "./tooltip-icon-button"; import { useThreadListItemRuntime } from "@assistant-ui/react"; -import { Button, Flex } from "antd"; +import { Button, Flex, Input } from "antd"; +import { trans } from "i18n"; import styled from "styled-components"; -import { useChatContext } from "../context/ChatContext"; const StyledPrimaryButton = styled(Button)` // padding: 20px; @@ -33,7 +34,7 @@ const ThreadListNew: FC = () => { return ( }> - New Thread + {trans("chat.newThread")} ); @@ -44,12 +45,23 @@ const ThreadListItems: FC = () => { }; const ThreadListItem: FC = () => { + const [editing, setEditing] = useState(false); + return ( - + {editing ? ( + setEditing(false)} + /> + ) : ( + + )} - + setEditing(true)} + editing={editing} + /> ); @@ -58,7 +70,7 @@ const ThreadListItem: FC = () => { const ThreadListItemTitle: FC = () => { return (

- +

); }; @@ -78,37 +90,57 @@ const ThreadListItemDelete: FC = () => { }; -const ThreadListItemRename: FC = () => { - const runtime = useThreadListItemRuntime(); + +const ThreadListItemEditInput: FC<{ onFinish: () => void }> = ({ onFinish }) => { + const threadItem = useThreadListItem(); + const threadRuntime = useThreadListItemRuntime(); - const handleClick = async () => { - // runtime doesn't expose a direct `title` prop; read it from its state - let current = ""; - try { - // getState is part of the public runtime surface - current = (runtime.getState?.() as any)?.title ?? ""; - } catch { - // fallback – generate a title if the runtime provides a helper - if (typeof (runtime as any).generateTitle === "function") { - // generateTitle(threadId) in older builds, generateTitle() in newer ones - current = (runtime as any).generateTitle((runtime as any).threadId ?? undefined); - } + const currentTitle = threadItem?.title || trans("chat.newChatTitle"); + + const handleRename = async (newTitle: string) => { + if (!newTitle.trim() || newTitle === currentTitle){ + onFinish(); + return; } - - const next = prompt("Rename thread", current)?.trim(); - if (next && next !== current) { - await runtime.rename(next); + + try { + await threadRuntime.rename(newTitle); + onFinish(); + } catch (error) { + console.error("Failed to rename thread:", error); } }; + return ( + handleRename(e.target.value)} + onPressEnter={(e) => handleRename((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === 'Escape') onFinish(); + }} + autoFocus + style={{ fontSize: '14px', padding: '2px 8px' }} + /> + ); +}; + + +const ThreadListItemRename: FC<{ onStartEdit: () => void; editing: boolean }> = ({ + onStartEdit, + editing +}) => { + if (editing) return null; + return ( ); -}; \ No newline at end of file +}; + 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 ae3749fb77..4018cbe5da 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 @@ -6,6 +6,7 @@ import { ThreadPrimitive, } from "@assistant-ui/react"; import type { FC } from "react"; + import { trans } from "i18n"; import { ArrowDownIcon, CheckIcon, @@ -21,10 +22,43 @@ import { import { Button } from "../ui/button"; import { MarkdownText } from "./markdown-text"; import { TooltipIconButton } from "./tooltip-icon-button"; + import { Spin, Flex } from "antd"; + import { LoadingOutlined } from "@ant-design/icons"; + import styled from "styled-components"; +import { ComposerAddAttachment, ComposerAttachments } from "../ui/attachment"; + const SimpleANTDLoader = () => { + const antIcon = ; + + return ( +
+ + + Working on it... + +
+ ); + }; + + const StyledThreadRoot = styled(ThreadPrimitive.Root)` + /* Hide entire assistant message container when it contains running status */ + .aui-assistant-message-root:has([data-status="running"]) { + display: none; + } - export const Thread: FC = () => { + /* Fallback for older browsers that don't support :has() */ + .aui-assistant-message-content [data-status="running"] { + display: none; + } +`; + + + interface ThreadProps { + placeholder?: string; + } + + export const Thread: FC = ({ placeholder = trans("chat.composerPlaceholder") }) => { return ( - + + + +
@@ -47,10 +85,10 @@ import {
- +
- + ); }; @@ -74,7 +112,7 @@ import {

- How can I help you today? + {trans("chat.welcomeMessage")}

@@ -88,35 +126,37 @@ import {
- What is the weather in Tokyo? + {trans("chat.suggestionWeather")} - What is assistant-ui? + {trans("chat.suggestionAssistant")}
); }; - const Composer: FC = () => { + const Composer: FC<{ placeholder?: string }> = ({ placeholder = trans("chat.composerPlaceholder") }) => { return ( + + 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 41ef892af4..1a31222a9a 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 @@ -1,378 +1,396 @@ -import React, { createContext, useContext, useReducer, useEffect, ReactNode } from "react"; -import { chatStorage, ThreadData as StoredThreadData } from "../../utils/chatStorage"; - -// Define thread-specific message type -export interface MyMessage { - id: string; - role: "user" | "assistant"; - text: string; - timestamp: number; -} - -// Thread data interfaces -export interface RegularThreadData { - threadId: string; - status: "regular"; - title: string; -} - -export interface ArchivedThreadData { - threadId: string; - status: "archived"; - title: string; -} - -export type ThreadData = RegularThreadData | ArchivedThreadData; - -// Chat state interface -interface ChatState { - isInitialized: boolean; - isLoading: boolean; - currentThreadId: string; - threadList: ThreadData[]; - threads: Map; - lastSaved: number; // Timestamp for tracking when data was last saved -} - -// Action types -type ChatAction = - | { type: "INITIALIZE_START" } - | { type: "INITIALIZE_SUCCESS"; threadList: ThreadData[]; threads: Map; currentThreadId: string } - | { type: "INITIALIZE_ERROR" } - | { type: "SET_CURRENT_THREAD"; threadId: string } - | { type: "ADD_THREAD"; thread: ThreadData } - | { type: "UPDATE_THREAD"; threadId: string; updates: Partial } - | { type: "DELETE_THREAD"; threadId: string } - | { type: "SET_MESSAGES"; threadId: string; messages: MyMessage[] } - | { type: "ADD_MESSAGE"; threadId: string; message: MyMessage } - | { type: "UPDATE_MESSAGES"; threadId: string; messages: MyMessage[] } - | { type: "MARK_SAVED" }; - -// Initial state -const initialState: ChatState = { - isInitialized: false, - isLoading: false, - currentThreadId: "default", - threadList: [{ threadId: "default", status: "regular", title: "New Chat" }], - threads: new Map([["default", []]]), - lastSaved: 0, -}; - -// Reducer function -function chatReducer(state: ChatState, action: ChatAction): ChatState { - switch (action.type) { - case "INITIALIZE_START": - return { - ...state, - isLoading: true, - }; - - case "INITIALIZE_SUCCESS": - return { - ...state, - isInitialized: true, - isLoading: false, - threadList: action.threadList, - threads: action.threads, - currentThreadId: action.currentThreadId, - lastSaved: Date.now(), - }; - - case "INITIALIZE_ERROR": - return { - ...state, - isInitialized: true, - isLoading: false, - }; - - case "SET_CURRENT_THREAD": - return { - ...state, - currentThreadId: action.threadId, - }; - - case "ADD_THREAD": - return { - ...state, - threadList: [...state.threadList, action.thread], - threads: new Map(state.threads).set(action.thread.threadId, []), - }; - - case "UPDATE_THREAD": - return { - ...state, - threadList: state.threadList.map(thread => - thread.threadId === action.threadId - ? { ...thread, ...action.updates } - : thread - ), - }; - - case "DELETE_THREAD": - const newThreads = new Map(state.threads); - newThreads.delete(action.threadId); - return { - ...state, - threadList: state.threadList.filter(t => t.threadId !== action.threadId), - threads: newThreads, - currentThreadId: state.currentThreadId === action.threadId - ? "default" - : state.currentThreadId, - }; - - case "SET_MESSAGES": - return { - ...state, - threads: new Map(state.threads).set(action.threadId, action.messages), - }; - - case "ADD_MESSAGE": - const currentMessages = state.threads.get(action.threadId) || []; - return { - ...state, - threads: new Map(state.threads).set(action.threadId, [...currentMessages, action.message]), - }; - - case "UPDATE_MESSAGES": - return { - ...state, - threads: new Map(state.threads).set(action.threadId, action.messages), - }; - - case "MARK_SAVED": - return { - ...state, - lastSaved: Date.now(), - }; - - default: - return state; - } -} - -// Context type -interface ChatContextType { - state: ChatState; - actions: { - // Initialization - initialize: () => Promise; - - // Thread management - setCurrentThread: (threadId: string) => void; - createThread: (title?: string) => Promise; - updateThread: (threadId: string, updates: Partial) => Promise; - deleteThread: (threadId: string) => Promise; - - // Message management - addMessage: (threadId: string, message: MyMessage) => Promise; - updateMessages: (threadId: string, messages: MyMessage[]) => Promise; - - // Utility - getCurrentMessages: () => MyMessage[]; - }; -} - -// Create the context -const ChatContext = createContext(null); - -// Chat provider component -export function ChatProvider({ children }: { children: ReactNode }) { - const [state, dispatch] = useReducer(chatReducer, initialState); - - // Initialize data from storage - const initialize = async () => { - dispatch({ type: "INITIALIZE_START" }); - - try { - await chatStorage.initialize(); - - // Load all threads from storage - const storedThreads = await chatStorage.getAllThreads(); - - if (storedThreads.length > 0) { - // Convert stored threads to UI format - const uiThreads: ThreadData[] = storedThreads.map(stored => ({ - threadId: stored.threadId, - status: stored.status as "regular" | "archived", - title: stored.title, - })); - - // Load messages for each thread - const threadMessages = new Map(); - for (const thread of storedThreads) { - const messages = await chatStorage.getMessages(thread.threadId); - threadMessages.set(thread.threadId, messages); - } - - // Ensure default thread exists - if (!threadMessages.has("default")) { - threadMessages.set("default", []); - } - - // Find the most recently updated thread - const latestThread = storedThreads.sort((a, b) => b.updatedAt - a.updatedAt)[0]; - const currentThreadId = latestThread ? latestThread.threadId : "default"; - - dispatch({ - type: "INITIALIZE_SUCCESS", - threadList: uiThreads, - threads: threadMessages, - currentThreadId - }); - } else { - // Initialize with default thread - const defaultThread: StoredThreadData = { - threadId: "default", - status: "regular", - title: "New Chat", - createdAt: Date.now(), - updatedAt: Date.now(), - }; - await chatStorage.saveThread(defaultThread); - - dispatch({ - type: "INITIALIZE_SUCCESS", - threadList: initialState.threadList, - threads: initialState.threads, - currentThreadId: "default" - }); - } - } catch (error) { - console.error("Failed to initialize chat data:", error); - dispatch({ type: "INITIALIZE_ERROR" }); - } - }; - - // Thread management actions - const setCurrentThread = (threadId: string) => { - dispatch({ type: "SET_CURRENT_THREAD", threadId }); - }; - - const createThread = async (title: string = "New Chat"): Promise => { - const threadId = `thread-${Date.now()}`; - const newThread: ThreadData = { - threadId, - status: "regular", - title, - }; - - // Update local state first - dispatch({ type: "ADD_THREAD", thread: newThread }); - - // Save to storage - try { - const storedThread: StoredThreadData = { - threadId, - status: "regular", - title, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - await chatStorage.saveThread(storedThread); - dispatch({ type: "MARK_SAVED" }); - } catch (error) { - console.error("Failed to save new thread:", error); - } - - return threadId; - }; - - const updateThread = async (threadId: string, updates: Partial) => { - // Update local state first - dispatch({ type: "UPDATE_THREAD", threadId, updates }); - - // Save to storage - try { - const existingThread = await chatStorage.getThread(threadId); - if (existingThread) { - const updatedThread: StoredThreadData = { - ...existingThread, - ...updates, - updatedAt: Date.now(), - }; - await chatStorage.saveThread(updatedThread); - dispatch({ type: "MARK_SAVED" }); - } - } catch (error) { - console.error("Failed to update thread:", error); - } - }; - - const deleteThread = async (threadId: string) => { - // Update local state first - dispatch({ type: "DELETE_THREAD", threadId }); - - // Delete from storage - try { - await chatStorage.deleteThread(threadId); - dispatch({ type: "MARK_SAVED" }); - } catch (error) { - console.error("Failed to delete thread:", error); - } - }; - - // Message management actions - const addMessage = async (threadId: string, message: MyMessage) => { - // Update local state first - dispatch({ type: "ADD_MESSAGE", threadId, message }); - - // Save to storage - try { - await chatStorage.saveMessage(message, threadId); - dispatch({ type: "MARK_SAVED" }); - } catch (error) { - console.error("Failed to save message:", error); - } - }; - - const updateMessages = async (threadId: string, messages: MyMessage[]) => { - // Update local state first - dispatch({ type: "UPDATE_MESSAGES", threadId, messages }); - - // Save to storage - try { - await chatStorage.saveMessages(messages, threadId); - dispatch({ type: "MARK_SAVED" }); - } catch (error) { - console.error("Failed to save messages:", error); - } - }; - - // Utility functions - const getCurrentMessages = (): MyMessage[] => { - return state.threads.get(state.currentThreadId) || []; - }; - - // Auto-initialize on mount - useEffect(() => { - if (!state.isInitialized && !state.isLoading) { - initialize(); - } - }, [state.isInitialized, state.isLoading]); - - const actions = { - initialize, - setCurrentThread, - createThread, - updateThread, - deleteThread, - addMessage, - updateMessages, - getCurrentMessages, - }; - - return ( - - {children} - - ); -} - -// Hook for accessing chat context -export function useChatContext() { - const context = useContext(ChatContext); - if (!context) { - throw new Error("useChatContext must be used within ChatProvider"); - } - return context; -} \ No newline at end of file +// client/packages/lowcoder/src/comps/comps/chatComp/context/ChatContext.tsx + +import React, { createContext, useContext, useReducer, useEffect, ReactNode } from "react"; +import { ChatStorage, ChatMessage, ChatThread } from "../../types/chatTypes"; +import { trans } from "i18n"; + +// ============================================================================ +// UPDATED CONTEXT WITH CLEAN TYPES +// ============================================================================ + +// Thread data interfaces (using clean types) +export interface RegularThreadData { + threadId: string; + status: "regular"; + title: string; +} + +export interface ArchivedThreadData { + threadId: string; + status: "archived"; + title: string; +} + +export type ThreadData = RegularThreadData | ArchivedThreadData; + +// Chat state interface (cleaned up) +interface ChatState { + isInitialized: boolean; + isLoading: boolean; + currentThreadId: string; + threadList: ThreadData[]; + threads: Map; + lastSaved: number; +} + +// Action types (same as before) +type ChatAction = + | { type: "INITIALIZE_START" } + | { type: "INITIALIZE_SUCCESS"; threadList: ThreadData[]; threads: Map; currentThreadId: string } + | { type: "INITIALIZE_ERROR" } + | { type: "SET_CURRENT_THREAD"; threadId: string } + | { type: "ADD_THREAD"; thread: ThreadData } + | { type: "UPDATE_THREAD"; threadId: string; updates: Partial } + | { type: "DELETE_THREAD"; threadId: string } + | { type: "SET_MESSAGES"; threadId: string; messages: ChatMessage[] } + | { type: "ADD_MESSAGE"; threadId: string; message: ChatMessage } + | { type: "UPDATE_MESSAGES"; threadId: string; messages: ChatMessage[] } + | { type: "MARK_SAVED" }; + +// Initial state +const initialState: ChatState = { + isInitialized: false, + isLoading: false, + currentThreadId: "default", + threadList: [{ threadId: "default", status: "regular", title: trans("chat.newChatTitle") }], + threads: new Map([["default", []]]), + lastSaved: 0, +}; + +// Reducer function (same logic, updated types) +function chatReducer(state: ChatState, action: ChatAction): ChatState { + switch (action.type) { + case "INITIALIZE_START": + return { + ...state, + isLoading: true, + }; + + case "INITIALIZE_SUCCESS": + return { + ...state, + isInitialized: true, + isLoading: false, + threadList: action.threadList, + threads: action.threads, + currentThreadId: action.currentThreadId, + lastSaved: Date.now(), + }; + + case "INITIALIZE_ERROR": + return { + ...state, + isInitialized: true, + isLoading: false, + }; + + case "SET_CURRENT_THREAD": + return { + ...state, + currentThreadId: action.threadId, + }; + + case "ADD_THREAD": + return { + ...state, + threadList: [...state.threadList, action.thread], + threads: new Map(state.threads).set(action.thread.threadId, []), + }; + + case "UPDATE_THREAD": + return { + ...state, + threadList: state.threadList.map(thread => + thread.threadId === action.threadId + ? { ...thread, ...action.updates } + : thread + ), + }; + + case "DELETE_THREAD": + const newThreads = new Map(state.threads); + newThreads.delete(action.threadId); + return { + ...state, + threadList: state.threadList.filter(t => t.threadId !== action.threadId), + threads: newThreads, + currentThreadId: state.currentThreadId === action.threadId + ? "default" + : state.currentThreadId, + }; + + case "SET_MESSAGES": + return { + ...state, + threads: new Map(state.threads).set(action.threadId, action.messages), + }; + + case "ADD_MESSAGE": + const currentMessages = state.threads.get(action.threadId) || []; + return { + ...state, + threads: new Map(state.threads).set(action.threadId, [...currentMessages, action.message]), + }; + + case "UPDATE_MESSAGES": + return { + ...state, + threads: new Map(state.threads).set(action.threadId, action.messages), + }; + + case "MARK_SAVED": + return { + ...state, + lastSaved: Date.now(), + }; + + default: + return state; + } +} + +// Context type (cleaned up) +interface ChatContextType { + state: ChatState; + actions: { + // Initialization + initialize: () => Promise; + + // Thread management + setCurrentThread: (threadId: string) => void; + createThread: (title?: string) => Promise; + updateThread: (threadId: string, updates: Partial) => Promise; + deleteThread: (threadId: string) => Promise; + + // Message management + addMessage: (threadId: string, message: ChatMessage) => Promise; + updateMessages: (threadId: string, messages: ChatMessage[]) => Promise; + + // Utility + getCurrentMessages: () => ChatMessage[]; + }; +} + +// Create the context +const ChatContext = createContext(null); + +// ============================================================================ +// CHAT PROVIDER - UPDATED TO USE CLEAN STORAGE INTERFACE +// ============================================================================ + +export function ChatProvider({ children, storage }: { + children: ReactNode; + storage: ChatStorage; +}) { + const [state, dispatch] = useReducer(chatReducer, initialState); + + // Initialize data from storage + const initialize = async () => { + dispatch({ type: "INITIALIZE_START" }); + + try { + await storage.initialize(); + + // Load all threads from storage + const storedThreads = await storage.getAllThreads(); + + if (storedThreads.length > 0) { + // Convert stored threads to UI format + const uiThreads: ThreadData[] = storedThreads.map(stored => ({ + threadId: stored.threadId, + status: stored.status as "regular" | "archived", + title: stored.title, + })); + + // Load messages for each thread + const threadMessages = new Map(); + for (const thread of storedThreads) { + const messages = await storage.getMessages(thread.threadId); + threadMessages.set(thread.threadId, messages); + } + + // Ensure default thread exists + if (!threadMessages.has("default")) { + threadMessages.set("default", []); + } + + // Find the most recently updated thread + const latestThread = storedThreads.sort((a, b) => b.updatedAt - a.updatedAt)[0]; + const currentThreadId = latestThread ? latestThread.threadId : "default"; + + dispatch({ + type: "INITIALIZE_SUCCESS", + threadList: uiThreads, + threads: threadMessages, + currentThreadId + }); + } else { + // Initialize with default thread + const defaultThread: ChatThread = { + threadId: "default", + status: "regular", + title: trans("chat.newChatTitle"), + createdAt: Date.now(), + updatedAt: Date.now(), + }; + await storage.saveThread(defaultThread); + + dispatch({ + type: "INITIALIZE_SUCCESS", + threadList: initialState.threadList, + threads: initialState.threads, + currentThreadId: "default" + }); + } + } catch (error) { + console.error("Failed to initialize chat data:", error); + dispatch({ type: "INITIALIZE_ERROR" }); + } + }; + + // Thread management actions (same logic, cleaner types) + const setCurrentThread = (threadId: string) => { + dispatch({ type: "SET_CURRENT_THREAD", threadId }); + }; + + const createThread = async (title: string = trans("chat.newChatTitle")): Promise => { + const threadId = `thread-${Date.now()}`; + const newThread: ThreadData = { + threadId, + status: "regular", + title, + }; + + // Update local state first + dispatch({ type: "ADD_THREAD", thread: newThread }); + + // Save to storage + try { + const storedThread: ChatThread = { + threadId, + status: "regular", + title, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + await storage.saveThread(storedThread); + dispatch({ type: "MARK_SAVED" }); + } catch (error) { + console.error("Failed to save new thread:", error); + } + + return threadId; + }; + + const updateThread = async (threadId: string, updates: Partial) => { + // Update local state first + dispatch({ type: "UPDATE_THREAD", threadId, updates }); + + // Save to storage + try { + const existingThread = await storage.getThread(threadId); + if (existingThread) { + const updatedThread: ChatThread = { + ...existingThread, + ...updates, + updatedAt: Date.now(), + }; + await storage.saveThread(updatedThread); + dispatch({ type: "MARK_SAVED" }); + } + } catch (error) { + console.error("Failed to update thread:", error); + } + }; + + const deleteThread = async (threadId: string) => { + // Determine if this is the last remaining thread BEFORE we delete it + const isLastThread = state.threadList.length === 1; + + // Update local state first + dispatch({ type: "DELETE_THREAD", threadId }); + + // Delete from storage + try { + await storage.deleteThread(threadId); + dispatch({ type: "MARK_SAVED" }); + // avoid deleting the last thread + // if there are no threads left, create a new one + // avoid infinite re-renders + if (isLastThread) { + const newThreadId = await createThread(trans("chat.newChatTitle")); + setCurrentThread(newThreadId); + } + } catch (error) { + console.error("Failed to delete thread:", error); + } + }; + + // Message management actions (same logic) + const addMessage = async (threadId: string, message: ChatMessage) => { + // Update local state first + dispatch({ type: "ADD_MESSAGE", threadId, message }); + + // Save to storage + try { + await storage.saveMessage(message, threadId); + dispatch({ type: "MARK_SAVED" }); + } catch (error) { + console.error("Failed to save message:", error); + } + }; + + const updateMessages = async (threadId: string, messages: ChatMessage[]) => { + // Update local state first + dispatch({ type: "UPDATE_MESSAGES", threadId, messages }); + + // Save to storage + try { + await storage.saveMessages(messages, threadId); + dispatch({ type: "MARK_SAVED" }); + } catch (error) { + console.error("Failed to save messages:", error); + } + }; + + // Utility functions + const getCurrentMessages = (): ChatMessage[] => { + return state.threads.get(state.currentThreadId) || []; + }; + + // Auto-initialize on mount + useEffect(() => { + if (!state.isInitialized && !state.isLoading) { + initialize(); + } + }, [state.isInitialized, state.isLoading]); + + const actions = { + initialize, + setCurrentThread, + createThread, + updateThread, + deleteThread, + addMessage, + updateMessages, + getCurrentMessages, + }; + + return ( + + {children} + + ); +} + +// Hook for accessing chat context +export function useChatContext() { + const context = useContext(ChatContext); + if (!context) { + throw new Error("useChatContext must be used within ChatProvider"); + } + return context; +} + +// Re-export types for convenience +export type { ChatMessage, ChatThread }; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx new file mode 100644 index 0000000000..1e430e5b32 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx @@ -0,0 +1,346 @@ +"use client"; + +import { PropsWithChildren, useEffect, useState, type FC } from "react"; +import { CircleXIcon, FileIcon, PaperclipIcon } from "lucide-react"; +import { + AttachmentPrimitive, + ComposerPrimitive, + MessagePrimitive, + useAttachment, +} from "@assistant-ui/react"; +import styled from "styled-components"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "./tooltip"; +import { + Dialog, + DialogTitle, + DialogTrigger, + DialogOverlay, + DialogPortal, + DialogContent, +} from "./dialog"; +import { Avatar, AvatarImage, AvatarFallback } from "./avatar"; +import { TooltipIconButton } from "../assistant-ui/tooltip-icon-button"; + +// ============================================================================ +// STYLED COMPONENTS +// ============================================================================ + +const StyledDialogTrigger = styled(DialogTrigger)` + cursor: pointer; + transition: background-color 0.2s; + padding: 2px; + border-radius: 4px; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + } +`; + +const StyledAvatar = styled(Avatar)` + background-color: #f1f5f9; + display: flex; + width: 40px; + height: 40px; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid #e2e8f0; + font-size: 14px; +`; + +const AttachmentContainer = styled.div` + display: flex; + height: 48px; + width: 160px; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 8px; + border: 1px solid #e2e8f0; + padding: 4px; +`; + +const AttachmentTextContainer = styled.div` + flex-grow: 1; + flex-basis: 0; + overflow: hidden; +`; + +const AttachmentName = styled.p` + color: #64748b; + font-size: 12px; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + word-break: break-all; + margin: 0; + line-height: 16px; +`; + +const AttachmentType = styled.p` + color: #64748b; + font-size: 12px; + margin: 0; + line-height: 16px; +`; + +const AttachmentRoot = styled(AttachmentPrimitive.Root)` + position: relative; + margin-top: 12px; +`; + +const StyledTooltipIconButton = styled(TooltipIconButton)` + color: #64748b; + position: absolute; + right: -12px; + top: -12px; + width: 24px; + height: 24px; + + & svg { + background-color: white; + width: 16px; + height: 16px; + border-radius: 50%; + } +`; + +const UserAttachmentsContainer = styled.div` + display: flex; + width: 100%; + flex-direction: row; + gap: 12px; + grid-column: 1 / -1; + grid-row-start: 1; + justify-content: flex-end; +`; + +const ComposerAttachmentsContainer = styled.div` + display: flex; + width: 100%; + flex-direction: row; + gap: 12px; + overflow-x: auto; +`; + +const StyledComposerButton = styled(TooltipIconButton)` + margin: 10px 0; + width: 32px; + height: 32px; + padding: 8px; + transition: opacity 0.2s ease-in; +`; + +const ScreenReaderOnly = styled.span` + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +`; + +// ============================================================================ +// UTILITY HOOKS +// ============================================================================ + +// Simple replacement for useShallow (removes zustand dependency) +const useShallow = (selector: (state: any) => T): ((state: any) => T) => selector; + +const useFileSrc = (file: File | undefined) => { + const [src, setSrc] = useState(undefined); + + useEffect(() => { + if (!file) { + setSrc(undefined); + return; + } + + const objectUrl = URL.createObjectURL(file); + setSrc(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [file]); + + return src; +}; + +const useAttachmentSrc = () => { + const { file, src } = useAttachment( + useShallow((a): { file?: File; src?: string } => { + if (a.type !== "image") return {}; + if (a.file) return { file: a.file }; + const src = a.content?.filter((c: any) => c.type === "image")[0]?.image; + if (!src) return {}; + return { src }; + }) + ); + + return useFileSrc(file) ?? src; +}; + +// ============================================================================ +// ATTACHMENT COMPONENTS +// ============================================================================ + +type AttachmentPreviewProps = { + src: string; +}; + +const AttachmentPreview: FC = ({ src }) => { + const [isLoaded, setIsLoaded] = useState(false); + + return ( + setIsLoaded(true)} + alt="Preview" + /> + ); +}; + +const AttachmentPreviewDialog: FC = ({ children }) => { + const src = useAttachmentSrc(); + + if (!src) return <>{children}; + + return ( + + + {children} + + + + Image Attachment Preview + + + + + ); +}; + +const AttachmentThumb: FC = () => { + const isImage = useAttachment((a) => a.type === "image"); + const src = useAttachmentSrc(); + return ( + + + + + + + ); +}; + +const AttachmentUI: FC = () => { + const canRemove = useAttachment((a) => a.source !== "message"); + const typeLabel = useAttachment((a) => { + const type = a.type; + switch (type) { + case "image": + return "Image"; + case "document": + return "Document"; + case "file": + return "File"; + default: + const _exhaustiveCheck: never = type; + throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`); + } + }); + + return ( + + + + + + + + + + + {typeLabel} + + + + + {canRemove && } + + + + + + ); +}; + +const AttachmentRemove: FC = () => { + return ( + + + + + + ); +}; + +// ============================================================================ +// EXPORTED COMPONENTS +// ============================================================================ + +export const UserMessageAttachments: FC = () => { + return ( + + + + ); +}; + +export const ComposerAttachments: FC = () => { + return ( + + + + ); +}; + +export const ComposerAddAttachment: FC = () => { + return ( + + + + + + ); +}; + +const AttachmentDialogContent: FC = ({ children }) => ( + + + + {children} + + +); \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/avatar.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/avatar.tsx new file mode 100644 index 0000000000..aa9032abc1 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/avatar.tsx @@ -0,0 +1,72 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" +import styled from "styled-components" + +const StyledAvatarRoot = styled(AvatarPrimitive.Root)` + position: relative; + display: flex; + width: 32px; + height: 32px; + flex-shrink: 0; + overflow: hidden; + border-radius: 50%; +`; + +const StyledAvatarImage = styled(AvatarPrimitive.Image)` + aspect-ratio: 1; + width: 100%; + height: 100%; +`; + +const StyledAvatarFallback = styled(AvatarPrimitive.Fallback)` + background-color: #f1f5f9; + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + border-radius: 50%; +`; + +function Avatar({ + className, + ...props +}: Omit, 'ref'>) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: Omit, 'ref'>) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: Omit, 'ref'>) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/dialog.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/dialog.tsx new file mode 100644 index 0000000000..058caebae3 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/dialog.tsx @@ -0,0 +1,230 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" +import styled from "styled-components" + +const StyledDialogOverlay = styled(DialogPrimitive.Overlay)` + position: fixed; + inset: 0; + z-index: 50; + background-color: rgba(0, 0, 0, 0.5); +`; + +const StyledDialogContent = styled(DialogPrimitive.Content)` + background-color: white; + position: fixed; + top: 50%; + left: 50%; + z-index: 50; + display: grid; + width: 100%; + max-width: calc(100% - 2rem); + transform: translate(-50%, -50%); + gap: 16px; + border-radius: 8px; + border: 1px solid #e2e8f0; + padding: 24px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + + @media (min-width: 640px) { + max-width: 512px; + } +`; + +const StyledDialogClose = styled(DialogPrimitive.Close)` + position: absolute; + top: 16px; + right: 16px; + border-radius: 4px; + opacity: 0.7; + transition: opacity 0.2s; + border: none; + background: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + opacity: 1; + } + + & svg { + width: 16px; + height: 16px; + } +`; + +const StyledDialogHeader = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + text-align: center; + + @media (min-width: 640px) { + text-align: left; + } +`; + +const StyledDialogFooter = styled.div` + display: flex; + flex-direction: column-reverse; + gap: 8px; + + @media (min-width: 640px) { + flex-direction: row; + justify-content: flex-end; + } +`; + +const StyledDialogTitle = styled(DialogPrimitive.Title)` + font-size: 18px; + line-height: 1; + font-weight: 600; +`; + +const StyledDialogDescription = styled(DialogPrimitive.Description)` + color: #64748b; + font-size: 14px; +`; + +const ScreenReaderOnly = styled.span` + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +`; + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: Omit, 'ref'>) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: Omit, 'ref'> & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + ) +} + +function DialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + ) +} + +function DialogTitle({ + className, + ...props +}: Omit, 'ref'>) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: Omit, 'ref'>) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts b/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts new file mode 100644 index 0000000000..1d674d04eb --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts @@ -0,0 +1,122 @@ +// client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts + +import { MessageHandler, MessageResponse, N8NHandlerConfig, QueryHandlerConfig } from "../types/chatTypes"; +import { CompAction, routeByNameAction, executeQueryAction } from "lowcoder-core"; +import { getPromiseAfterDispatch } from "util/promiseUtils"; + +// ============================================================================ +// N8N HANDLER (for Bottom Panel) +// ============================================================================ + +export class N8NHandler implements MessageHandler { + constructor(private config: N8NHandlerConfig) {} + + async sendMessage(message: string): Promise { + const { modelHost, systemPrompt, streaming } = this.config; + + if (!modelHost) { + throw new Error("Model host is required for N8N calls"); + } + + try { + const response = await fetch(modelHost, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message, + systemPrompt: systemPrompt || "You are a helpful assistant.", + streaming: streaming || false + }) + }); + + if (!response.ok) { + throw new Error(`N8N call failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // Extract content from various possible response formats + const content = data.response || data.message || data.content || data.text || String(data); + + return { content }; + } catch (error) { + throw new Error(`N8N call failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } +} + +// ============================================================================ +// QUERY HANDLER (for Canvas Components) +// ============================================================================ + +export class QueryHandler implements MessageHandler { + constructor(private config: QueryHandlerConfig) {} + + async sendMessage(message: string): Promise { + const { chatQuery, dispatch} = this.config; + + // If no query selected or dispatch unavailable, return mock response + if (!chatQuery || !dispatch) { + await new Promise((res) => setTimeout(res, 500)); + return { content: "(mock) You typed: " + message }; + } + + try { + + const result: any = await getPromiseAfterDispatch( + dispatch, + routeByNameAction( + chatQuery, + executeQueryAction({ + // Send both individual prompt and full conversation history + args: { + prompt: { value: message }, + }, + }) + ) + ); + + return result.message + } catch (e: any) { + throw new Error(e?.message || "Query execution failed"); + } + } +} + +// ============================================================================ +// MOCK HANDLER (for testing/fallbacks) +// ============================================================================ + +export class MockHandler implements MessageHandler { + constructor(private delay: number = 1000) {} + + async sendMessage(message: string): Promise { + await new Promise(resolve => setTimeout(resolve, this.delay)); + return { content: `Mock response: ${message}` }; + } +} + +// ============================================================================ +// HANDLER FACTORY (creates the right handler based on type) +// ============================================================================ + +export function createMessageHandler( + type: "n8n" | "query" | "mock", + config: N8NHandlerConfig | QueryHandlerConfig +): MessageHandler { + switch (type) { + case "n8n": + return new N8NHandler(config as N8NHandlerConfig); + + case "query": + return new QueryHandler(config as QueryHandlerConfig); + + case "mock": + return new MockHandler(); + + default: + throw new Error(`Unknown message handler type: ${type}`); + } +} \ 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 new file mode 100644 index 0000000000..25595b44df --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -0,0 +1,92 @@ +// client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts + +// ============================================================================ +// CORE MESSAGE AND THREAD TYPES (cleaned up from your existing types) +// ============================================================================ + +export interface ChatMessage { + id: string; + role: "user" | "assistant"; + text: string; + timestamp: number; + } + + export interface ChatThread { + threadId: string; + status: "regular" | "archived"; + title: string; + createdAt: number; + updatedAt: number; + } + + // ============================================================================ + // STORAGE INTERFACE (abstracted from your existing storage factory) + // ============================================================================ + + export interface ChatStorage { + initialize(): Promise; + saveThread(thread: ChatThread): Promise; + getThread(threadId: string): Promise; + getAllThreads(): Promise; + deleteThread(threadId: string): Promise; + saveMessage(message: ChatMessage, threadId: string): Promise; + saveMessages(messages: ChatMessage[], threadId: string): Promise; + getMessages(threadId: string): Promise; + deleteMessages(threadId: string): Promise; + clearAllData(): Promise; + resetDatabase(): Promise; + cleanup(): Promise; + } + + // ============================================================================ + // MESSAGE HANDLER INTERFACE (new clean abstraction) + // ============================================================================ + + export interface MessageHandler { + sendMessage(message: string): Promise; + // Future: sendMessageStream?(message: string): AsyncGenerator; + } + + export interface MessageResponse { + content: string; + metadata?: any; + } + + // ============================================================================ + // CONFIGURATION TYPES (simplified) + // ============================================================================ + + export interface N8NHandlerConfig { + modelHost: string; + systemPrompt?: string; + streaming?: boolean; + } + + 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; + } \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/utils/chatStorage.ts b/client/packages/lowcoder/src/comps/comps/chatComp/utils/chatStorage.ts deleted file mode 100644 index edc68a0d93..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatComp/utils/chatStorage.ts +++ /dev/null @@ -1,281 +0,0 @@ -import alasql from "alasql"; -import { MyMessage } from "../components/context/ChatContext"; - -// Database configuration -const DB_NAME = "ChatDB"; -const THREADS_TABLE = "threads"; -const MESSAGES_TABLE = "messages"; - -// Thread data interface -export interface ThreadData { - threadId: string; - status: "regular" | "archived"; - title: string; - createdAt: number; - updatedAt: number; -} - -// Initialize the database -class ChatStorage { - private initialized = false; - - async initialize() { - if (this.initialized) return; - - try { - // Create database with localStorage backend - await alasql.promise(`CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${DB_NAME}`); - await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${DB_NAME}`); - await alasql.promise(`USE ${DB_NAME}`); - - // Create threads table - await alasql.promise(` - CREATE TABLE IF NOT EXISTS ${THREADS_TABLE} ( - threadId STRING PRIMARY KEY, - status STRING, - title STRING, - createdAt NUMBER, - updatedAt NUMBER - ) - `); - - // Create messages table - await alasql.promise(` - CREATE TABLE IF NOT EXISTS ${MESSAGES_TABLE} ( - id STRING PRIMARY KEY, - threadId STRING, - role STRING, - text STRING, - timestamp NUMBER - ) - `); - - this.initialized = true; - console.log("Chat database initialized successfully"); - } catch (error) { - console.error("Failed to initialize chat database:", error); - throw error; - } - } - - // Thread operations - async saveThread(thread: ThreadData): Promise { - await this.initialize(); - - try { - // Insert or replace thread - await alasql.promise(` - DELETE FROM ${THREADS_TABLE} WHERE threadId = ? - `, [thread.threadId]); - - await alasql.promise(` - INSERT INTO ${THREADS_TABLE} VALUES (?, ?, ?, ?, ?) - `, [thread.threadId, thread.status, thread.title, thread.createdAt, thread.updatedAt]); - } catch (error) { - console.error("Failed to save thread:", error); - throw error; - } - } - - async getThread(threadId: string): Promise { - await this.initialize(); - - try { - const result = await alasql.promise(` - SELECT * FROM ${THREADS_TABLE} WHERE threadId = ? - `, [threadId]) as ThreadData[]; - - return result && result.length > 0 ? result[0] : null; - } catch (error) { - console.error("Failed to get thread:", error); - return null; - } - } - - async getAllThreads(): Promise { - await this.initialize(); - - try { - const result = await alasql.promise(` - SELECT * FROM ${THREADS_TABLE} ORDER BY updatedAt DESC - `) as ThreadData[]; - - return Array.isArray(result) ? result : []; - } catch (error) { - console.error("Failed to get threads:", error); - return []; - } - } - - async deleteThread(threadId: string): Promise { - await this.initialize(); - - try { - // Delete thread and all its messages - await alasql.promise(`DELETE FROM ${THREADS_TABLE} WHERE threadId = ?`, [threadId]); - await alasql.promise(`DELETE FROM ${MESSAGES_TABLE} WHERE threadId = ?`, [threadId]); - } catch (error) { - console.error("Failed to delete thread:", error); - throw error; - } - } - - // Message operations - async saveMessage(message: MyMessage, threadId: string): Promise { - await this.initialize(); - - try { - // Insert or replace message - await alasql.promise(` - DELETE FROM ${MESSAGES_TABLE} WHERE id = ? - `, [message.id]); - - await alasql.promise(` - INSERT INTO ${MESSAGES_TABLE} VALUES (?, ?, ?, ?, ?) - `, [message.id, threadId, message.role, message.text, message.timestamp]); - } catch (error) { - console.error("Failed to save message:", error); - throw error; - } - } - - async saveMessages(messages: MyMessage[], threadId: string): Promise { - await this.initialize(); - - try { - // Delete existing messages for this thread - await alasql.promise(`DELETE FROM ${MESSAGES_TABLE} WHERE threadId = ?`, [threadId]); - - // Insert all messages - for (const message of messages) { - await alasql.promise(` - INSERT INTO ${MESSAGES_TABLE} VALUES (?, ?, ?, ?, ?) - `, [message.id, threadId, message.role, message.text, message.timestamp]); - } - } catch (error) { - console.error("Failed to save messages:", error); - throw error; - } - } - - async getMessages(threadId: string): Promise { - await this.initialize(); - - try { - const result = await alasql.promise(` - SELECT id, role, text, timestamp FROM ${MESSAGES_TABLE} - WHERE threadId = ? ORDER BY timestamp ASC - `, [threadId]) as MyMessage[]; - - return Array.isArray(result) ? result : []; - } catch (error) { - console.error("Failed to get messages:", error); - return []; - } - } - - async deleteMessages(threadId: string): Promise { - await this.initialize(); - - try { - await alasql.promise(`DELETE FROM ${MESSAGES_TABLE} WHERE threadId = ?`, [threadId]); - } catch (error) { - console.error("Failed to delete messages:", error); - throw error; - } - } - - // Utility methods - async clearAllData(): Promise { - await this.initialize(); - - try { - await alasql.promise(`DELETE FROM ${THREADS_TABLE}`); - await alasql.promise(`DELETE FROM ${MESSAGES_TABLE}`); - } catch (error) { - console.error("Failed to clear all data:", error); - throw error; - } - } - - async resetDatabase(): Promise { - try { - // Drop the entire database - await alasql.promise(`DROP LOCALSTORAGE DATABASE IF EXISTS ${DB_NAME}`); - this.initialized = false; - - // Reinitialize fresh - await this.initialize(); - console.log("✅ Database reset and reinitialized"); - } catch (error) { - console.error("Failed to reset database:", error); - throw error; - } - } - - async clearOnlyMessages(): Promise { - await this.initialize(); - - try { - await alasql.promise(`DELETE FROM ${MESSAGES_TABLE}`); - console.log("✅ All messages cleared, threads preserved"); - } catch (error) { - console.error("Failed to clear messages:", error); - throw error; - } - } - - async clearOnlyThreads(): Promise { - await this.initialize(); - - try { - await alasql.promise(`DELETE FROM ${THREADS_TABLE}`); - await alasql.promise(`DELETE FROM ${MESSAGES_TABLE}`); // Clear orphaned messages - console.log("✅ All threads and messages cleared"); - } catch (error) { - console.error("Failed to clear threads:", error); - throw error; - } - } - - async exportData(): Promise<{ threads: ThreadData[]; messages: any[] }> { - await this.initialize(); - - try { - const threads = await this.getAllThreads(); - const messages = await alasql.promise(`SELECT * FROM ${MESSAGES_TABLE}`) as any[]; - - return { threads, messages: Array.isArray(messages) ? messages : [] }; - } catch (error) { - console.error("Failed to export data:", error); - throw error; - } - } - - async importData(data: { threads: ThreadData[]; messages: any[] }): Promise { - await this.initialize(); - - try { - // Clear existing data - await this.clearAllData(); - - // Import threads - for (const thread of data.threads) { - await this.saveThread(thread); - } - - // Import messages - for (const message of data.messages) { - await alasql.promise(` - INSERT INTO ${MESSAGES_TABLE} VALUES (?, ?, ?, ?, ?) - `, [message.id, message.threadId, message.role, message.text, message.timestamp]); - } - } catch (error) { - console.error("Failed to import data:", error); - throw error; - } - } -} - -// Export singleton instance -export const chatStorage = new ChatStorage(); \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts b/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts new file mode 100644 index 0000000000..cc563ba66d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts @@ -0,0 +1,186 @@ +// client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts + +import alasql from "alasql"; +import { ChatMessage, ChatThread, ChatStorage } from "../types/chatTypes"; + +// ============================================================================ +// CLEAN STORAGE FACTORY (simplified from your existing implementation) +// ============================================================================ + +export function createChatStorage(tableName: string): ChatStorage { + const dbName = `ChatDB_${tableName}`; + const threadsTable = `${dbName}.threads`; + const messagesTable = `${dbName}.messages`; + + return { + async initialize() { + try { + // Create database with localStorage backend + await alasql.promise(`CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${dbName}`); + await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${dbName}`); + + // Create threads table + await alasql.promise(` + CREATE TABLE IF NOT EXISTS ${threadsTable} ( + threadId STRING PRIMARY KEY, + status STRING, + title STRING, + createdAt NUMBER, + updatedAt NUMBER + ) + `); + + // Create messages table + await alasql.promise(` + CREATE TABLE IF NOT EXISTS ${messagesTable} ( + id STRING PRIMARY KEY, + threadId STRING, + role STRING, + text STRING, + timestamp NUMBER + ) + `); + + } catch (error) { + console.error(`Failed to initialize chat database ${dbName}:`, error); + throw error; + } + }, + + async saveThread(thread: ChatThread) { + try { + // Insert or replace thread + await alasql.promise(`DELETE FROM ${threadsTable} WHERE threadId = ?`, [thread.threadId]); + + await alasql.promise(` + INSERT INTO ${threadsTable} VALUES (?, ?, ?, ?, ?) + `, [thread.threadId, thread.status, thread.title, thread.createdAt, thread.updatedAt]); + } catch (error) { + console.error("Failed to save thread:", error); + throw error; + } + }, + + async getThread(threadId: string) { + try { + const result = await alasql.promise(` + SELECT * FROM ${threadsTable} WHERE threadId = ? + `, [threadId]) as ChatThread[]; + + return result && result.length > 0 ? result[0] : null; + } catch (error) { + console.error("Failed to get thread:", error); + return null; + } + }, + + async getAllThreads() { + try { + const result = await alasql.promise(` + SELECT * FROM ${threadsTable} ORDER BY updatedAt DESC + `) as ChatThread[]; + + return Array.isArray(result) ? result : []; + } catch (error) { + console.error("Failed to get threads:", error); + return []; + } + }, + + async deleteThread(threadId: string) { + try { + // Delete thread and all its messages + await alasql.promise(`DELETE FROM ${threadsTable} WHERE threadId = ?`, [threadId]); + await alasql.promise(`DELETE FROM ${messagesTable} WHERE threadId = ?`, [threadId]); + } catch (error) { + console.error("Failed to delete thread:", error); + throw error; + } + }, + + async saveMessage(message: ChatMessage, threadId: string) { + try { + // Insert or replace message + await alasql.promise(`DELETE FROM ${messagesTable} WHERE id = ?`, [message.id]); + + await alasql.promise(` + INSERT INTO ${messagesTable} VALUES (?, ?, ?, ?, ?) + `, [message.id, threadId, message.role, message.text, message.timestamp]); + } catch (error) { + console.error("Failed to save message:", error); + throw error; + } + }, + + async saveMessages(messages: ChatMessage[], threadId: string) { + try { + // Delete existing messages for this thread + await alasql.promise(`DELETE FROM ${messagesTable} WHERE threadId = ?`, [threadId]); + + // Insert all messages + for (const message of messages) { + await alasql.promise(` + INSERT INTO ${messagesTable} VALUES (?, ?, ?, ?, ?) + `, [message.id, threadId, message.role, message.text, message.timestamp]); + } + } catch (error) { + console.error("Failed to save messages:", error); + throw error; + } + }, + + async getMessages(threadId: string) { + try { + const result = await alasql.promise(` + SELECT id, role, text, timestamp FROM ${messagesTable} + WHERE threadId = ? ORDER BY timestamp ASC + `, [threadId]) as ChatMessage[]; + + return Array.isArray(result) ? result : []; + } catch (error) { + console.error("Failed to get messages:", error); + return []; + } + }, + + async deleteMessages(threadId: string) { + try { + await alasql.promise(`DELETE FROM ${messagesTable} WHERE threadId = ?`, [threadId]); + } catch (error) { + console.error("Failed to delete messages:", error); + throw error; + } + }, + + async clearAllData() { + try { + await alasql.promise(`DELETE FROM ${threadsTable}`); + await alasql.promise(`DELETE FROM ${messagesTable}`); + } catch (error) { + console.error("Failed to clear all data:", error); + throw error; + } + }, + + async resetDatabase() { + try { + // Drop the entire database + await alasql.promise(`DROP LOCALSTORAGE DATABASE IF EXISTS ${dbName}`); + + // Reinitialize fresh + await this.initialize(); + } catch (error) { + console.error("Failed to reset database:", error); + throw error; + } + }, + async cleanup() { + try { + await alasql.promise(`DROP LOCALSTORAGE DATABASE IF EXISTS ${dbName}`); + } catch (error) { + console.error("Failed to cleanup database:", error); + throw error; + } + } + }; +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index b6eb5ad312..bd4d0f54e6 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -1684,12 +1684,12 @@ export var uiCompMap: Registry = { }, }, chat: { - name: "Chat", - enName: "Chat", - description: "Chat Component", + name: trans("uiComp.chatCompName"), + enName: "AI Chat", + description: trans("uiComp.chatCompDesc"), categories: ["collaboration"], icon: CommentCompIcon, // Use existing icon for now - keywords: "chat,conversation", + keywords: trans("uiComp.chatCompKeywords"), comp: ChatComp, layoutInfo: { w: 12, diff --git a/client/packages/lowcoder/src/comps/queries/httpQuery/sseHttpQuery.tsx b/client/packages/lowcoder/src/comps/queries/httpQuery/sseHttpQuery.tsx new file mode 100644 index 0000000000..2271f582ef --- /dev/null +++ b/client/packages/lowcoder/src/comps/queries/httpQuery/sseHttpQuery.tsx @@ -0,0 +1,222 @@ +// SSEHTTPQUERY.tsx +import { Dropdown, ValueFromOption } from "components/Dropdown"; +import { QueryConfigItemWrapper, QueryConfigLabel, QueryConfigWrapper } from "components/query"; +import { valueComp, withDefault } from "comps/generators"; +import { trans } from "i18n"; +import { includes } from "lodash"; +import { CompAction, MultiBaseComp } from "lowcoder-core"; +import { keyValueListControl } from "../../controls/keyValueListControl"; +import { ParamsJsonControl, ParamsStringControl } from "../../controls/paramsControl"; +import { withTypeAndChildrenAbstract } from "../../generators/withType"; +import { toSseQueryView } from "../queryCompUtils"; +import { + HttpHeaderPropertyView, + HttpParametersPropertyView, + HttpPathPropertyView, +} from "./httpQueryConstants"; + +const BodyTypeOptions = [ + { label: "JSON", value: "application/json" }, + { label: "Raw", value: "text/plain" }, + { + label: "x-www-form-urlencoded", + value: "application/x-www-form-urlencoded", + }, + { label: "Form Data", value: "multipart/form-data" }, + { label: "None", value: "none" }, +] as const; +type BodyTypeValue = ValueFromOption; + +const HttpMethodOptions = [ + { label: "GET", value: "GET" }, + { label: "POST", value: "POST" }, + { label: "PUT", value: "PUT" }, + { label: "DELETE", value: "DELETE" }, + { label: "PATCH", value: "PATCH" }, + { label: "HEAD", value: "HEAD" }, + { label: "OPTIONS", value: "OPTIONS" }, + { label: "TRACE", value: "TRACE" }, +] as const; +type HttpMethodValue = ValueFromOption; + +const CommandMap = { + "application/json": ParamsJsonControl, + "text/plain": ParamsStringControl, + "application/x-www-form-urlencoded": ParamsStringControl, + "multipart/form-data": ParamsStringControl, + none: ParamsStringControl, +}; + +const childrenMap = { + httpMethod: valueComp("GET"), + path: ParamsStringControl, + headers: withDefault(keyValueListControl(), [ + { key: "Accept", value: "text/event-stream" } + ]), + params: withDefault(keyValueListControl(), [{ key: "", value: "" }]), + bodyFormData: withDefault( + keyValueListControl(true, [ + { label: trans("httpQuery.text"), value: "text" }, + { label: trans("httpQuery.file"), value: "file" }, + ] as const), + [{ key: "", value: "", type: "text" }] + ), + // Add SSE-specific configuration + streamingEnabled: valueComp(true), +}; + +const SseHttpTmpQuery = withTypeAndChildrenAbstract( + CommandMap, + "none", + childrenMap, + "bodyType", + "body" +); + +export class SseHttpQuery extends SseHttpTmpQuery { + isWrite(action: CompAction) { + return ( + action.path.includes("httpMethod") && "value" in action && !includes(["GET"], action.value) + ); + } + + override getView() { + const children = this.children; + const params = [ + ...children.headers.getQueryParams(), + ...children.params.getQueryParams(), + ...children.bodyFormData.getQueryParams(), + ...children.path.getQueryParams(), + ...children.body.getQueryParams(), + // Add streaming flag to params + { key: "_streaming", value: () => "true" }, + { key: "_streamingEnabled", value: () => children.streamingEnabled.getView() } + ]; + + // Use SSE-specific query view + return toSseQueryView(params); + } + + propertyView(props: { + datasourceId: string; + urlPlaceholder?: string; + supportHttpMethods?: HttpMethodValue[]; + supportBodyTypes?: BodyTypeValue[]; + }) { + return ; + } + + getHttpMethod() { + return this.children.httpMethod.getView(); + } +} + +type ChildrenType = InstanceType extends MultiBaseComp ? X : never; + +const ContentTypeKey = "Content-Type"; + +const showBodyConfig = (children: ChildrenType) => { + switch (children.bodyType.getView() as BodyTypeValue) { + case "application/x-www-form-urlencoded": + return children.bodyFormData.propertyView({}); + case "multipart/form-data": + return children.bodyFormData.propertyView({ + showType: true, + typeTooltip: trans("httpQuery.bodyFormDataTooltip", { + type: `"${trans("httpQuery.file")}"`, + object: "{ data: base64 string, name: string }", + example: "{{ {data: file1.value[0], name: file1.files[0].name} }}", + }), + }); + case "application/json": + case "text/plain": + return children.body.propertyView({ styleName: "medium", width: "100%" }); + default: + return <>; + } +}; + +const SseHttpQueryPropertyView = (props: { + comp: InstanceType; + datasourceId: string; + urlPlaceholder?: string; + supportHttpMethods?: HttpMethodValue[]; + supportBodyTypes?: BodyTypeValue[]; +}) => { + const { comp, supportHttpMethods, supportBodyTypes } = props; + const { children, dispatch } = comp; + + return ( + <> + !supportHttpMethods || supportHttpMethods.includes(o.value) + )} + label={"HTTP Method"} + onChange={(value: HttpMethodValue) => { + children.httpMethod.dispatchChangeValueAction(value); + }} + /> + + + + + + + + !supportBodyTypes || supportBodyTypes?.includes(o.value) + )} + value={children.bodyType.getView()} + onChange={(value) => { + let headers = children.headers + .toJsonValue() + .filter((header) => header.key !== ContentTypeKey); + + // Always ensure Accept: text/event-stream for SSE + const hasAcceptHeader = headers.some(h => h.key === "Accept"); + if (!hasAcceptHeader) { + headers.push({ key: "Accept", value: "text/event-stream" }); + } + + if (value !== "none") { + headers = [ + { + key: ContentTypeKey, + value: value, + }, + ...headers, + ]; + } + + dispatch( + comp.changeValueAction({ ...comp.toJsonValue(), bodyType: value, headers: headers }) + ); + }} + /> + + + + {showBodyConfig(children)} + + + + Streaming Options + +
+ This query will establish a Server-Sent Events connection for real-time data streaming. +
+
+
+ + ); +}; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/queries/queryCompUtils.tsx b/client/packages/lowcoder/src/comps/queries/queryCompUtils.tsx index bf49517af0..87f3926bc8 100644 --- a/client/packages/lowcoder/src/comps/queries/queryCompUtils.tsx +++ b/client/packages/lowcoder/src/comps/queries/queryCompUtils.tsx @@ -82,7 +82,7 @@ export function toQueryView(params: FunctionProperty[]) { }).map(({ key, value }) => ({ key, value: value(props.args) })), ...Object.entries(props.timeout.getView()).map(([key, value]) => ({ key, - value: value(props.args), + value: (value as ValueFunction)(props.args), })), ...mappedVariables, ], @@ -143,3 +143,362 @@ export function onlyManualTrigger(type: ResourceType) { export function getTriggerType(comp: any): TriggerType { return comp.children.triggerType.getView(); } + +// STREAMING QUERY + +export interface SseQueryResult extends QueryResult { + streamId?: string; + isStreaming?: boolean; +} + +export interface SseQueryViewProps { + queryId: string; + applicationId: string; + applicationPath: string[]; + args?: Record; + variables?: any; + timeout: any; + onStreamData?: (data: any) => void; + onStreamError?: (error: any) => void; + onStreamEnd?: () => void; +} + +/** + * SSE-specific query view that handles streaming responses + */ +export function toSseQueryView(params: FunctionProperty[]) { + // Store active connections + const activeConnections = new Map(); + + return async (props: SseQueryViewProps): Promise => { + const { applicationId, isViewMode } = getGlobalSettings(); + + // Process parameters similar to toQueryView + let mappedVariables: Array<{key: string, value: string}> = []; + Object.keys(props.variables || {}) + .filter(k => k !== "$queryName") + .forEach(key => { + const value = Object.hasOwn(props.variables[key], 'value') + ? props.variables[key].value + : props.variables[key]; + mappedVariables.push({ + key: `${key}.value`, + value: value || "" + }); + mappedVariables.push({ + key: `${props.args?.$queryName}.variables.${key}`, + value: value || "" + }); + }); + + let request: QueryExecuteRequest = { + path: props.applicationPath, + params: [ + ...params.filter(param => { + return !mappedVariables.map(v => v.key).includes(param.key); + }).map(({ key, value }) => ({ key, value: value(props.args) })), + ...Object.entries(props.timeout.getView()).map(([key, value]) => ({ + key, + value: (value as ValueFunction)(props.args), + })), + ...mappedVariables, + ], + viewMode: !!isViewMode, + }; + + if (!applicationId) { + request = { ...request, libraryQueryId: props.queryId, libraryQueryRecordId: "latest" }; + } else { + request = { ...request, applicationId: props.applicationId, queryId: props.queryId }; + } + + try { + // For SSE queries, we need a different approach + // Option 1: If your backend supports SSE proxying + const streamId = `sse_${props.queryId}_${Date.now()}`; + + // First, initiate the SSE connection through your backend + const initResponse = await QueryApi.executeQuery( + { + ...request, + // Add SSE-specific flags + params: [ + ...(request.params || []), + { key: "_sseInit", value: "true" }, + { key: "_streamId", value: streamId } + ] + }, + props.timeout.children.text.getView() as number + ); + + if (!initResponse.data.success) { + return { + ...initResponse.data, + code: initResponse.data.queryCode, + extra: _.omit(initResponse.data, ["code", "message", "data", "success", "runTime", "queryCode"]), + }; + } + + // Get the SSE endpoint from backend response + const sseEndpoint = (initResponse.data.data as any)?.sseEndpoint; + + if (sseEndpoint) { + // Establish SSE connection + establishSseConnection( + streamId, + sseEndpoint, + props.onStreamData, + props.onStreamError, + props.onStreamEnd, + activeConnections + ); + + return { + ...initResponse.data, + code: QUERY_EXECUTION_OK, + streamId, + isStreaming: true, + extra: { + ..._.omit(initResponse.data, ["code", "message", "data", "success", "runTime", "queryCode"]), + streamId, + closeStream: () => closeSseConnection(streamId, activeConnections) + } + }; + } + + // Fallback to regular response if SSE not available + return { + ...initResponse.data, + code: initResponse.data.queryCode, + extra: _.omit(initResponse.data, ["code", "message", "data", "success", "runTime", "queryCode"]), + }; + + } catch (error) { + return { + success: false, + data: "", + code: QUERY_EXECUTION_ERROR, + message: (error as any).message || "Failed to execute SSE query", + }; + } + }; +} + +function establishSseConnection( + streamId: string, + endpoint: string, + onData?: (data: any) => void, + onError?: (error: any) => void, + onEnd?: () => void, + connections?: Map +) { + // Close any existing connection with the same ID + if (connections?.has(streamId)) { + connections.get(streamId)?.close(); + } + + const eventSource = new EventSource(endpoint); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + onData?.(data); + } catch (error) { + // Handle non-JSON data + onData?.(event.data); + } + }; + + eventSource.onerror = (error) => { + onError?.(error); + eventSource.close(); + connections?.delete(streamId); + onEnd?.(); + }; + + eventSource.onopen = () => { + console.log(`SSE connection established: ${streamId}`); + }; + + // Store the connection + connections?.set(streamId, eventSource); +} + +function closeSseConnection(streamId: string, connections?: Map) { + const eventSource = connections?.get(streamId); + if (eventSource) { + eventSource.close(); + connections?.delete(streamId); + console.log(`SSE connection closed: ${streamId}`); + } +} + +// Alternative implementation using fetch with ReadableStream +export function toSseQueryViewWithFetch(params: FunctionProperty[]) { + const activeControllers = new Map(); + + return async (props: SseQueryViewProps): Promise => { + const { applicationId, isViewMode } = getGlobalSettings(); + + // Similar parameter processing as above... + let mappedVariables: Array<{key: string, value: string}> = []; + Object.keys(props.variables || {}) + .filter(k => k !== "$queryName") + .forEach(key => { + const value = Object.hasOwn(props.variables[key], 'value') + ? props.variables[key].value + : props.variables[key]; + mappedVariables.push({ + key: `${key}.value`, + value: value || "" + }); + }); + + const processedParams = [ + ...params.filter(param => { + return !mappedVariables.map(v => v.key).includes(param.key); + }).map(({ key, value }) => ({ key, value: value(props.args) })), + ...Object.entries(props.timeout.getView()).map(([key, value]) => ({ + key, + value: (value as ValueFunction)(props.args), + })), + ...mappedVariables, + ]; + + // Build the request configuration from params + const config = buildRequestConfig(processedParams); + + const streamId = `fetch_${props.queryId}_${Date.now()}`; + const controller = new AbortController(); + activeControllers.set(streamId, controller); + + try { + const response = await fetch(config.url, { + method: config.method, + headers: config.headers, + body: config.body, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Process the stream + if (response.body) { + processStream( + response.body, + props.onStreamData, + props.onStreamError, + props.onStreamEnd + ); + } + + return { + success: true, + data: { message: "Stream started" }, + code: QUERY_EXECUTION_OK, + streamId, + isStreaming: true, + runTime: 0, + extra: { + streamId, + closeStream: () => { + controller.abort(); + activeControllers.delete(streamId); + } + } + }; + + } catch (error) { + activeControllers.delete(streamId); + return { + success: false, + data: "", + code: QUERY_EXECUTION_ERROR, + message: (error as any).message || "Failed to establish stream", + }; + } + }; +} + +function buildRequestConfig(params: Array<{key: string, value: any}>) { + const config: any = { + url: "", + method: "GET", + headers: {}, + body: undefined, + }; + + params.forEach(param => { + if (param.key === "url" || param.key === "path") { + config.url = param.value; + } else if (param.key === "method") { + config.method = param.value; + } else if (param.key.startsWith("header.")) { + const headerName = param.key.substring(7); + config.headers[headerName] = param.value; + } else if (param.key === "body") { + config.body = param.value; + } + }); + + return config; +} + +async function processStream( + readableStream: ReadableStream, + onData?: (data: any) => void, + onError?: (error: any) => void, + onEnd?: () => void +) { + const reader = readableStream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + onEnd?.(); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // Process complete lines + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + // Handle SSE format + let data = line.trim(); + if (data.startsWith('data: ')) { + data = data.substring(6); + } + + // Skip control messages + if (data === '[DONE]' || data.startsWith('event:') || data.startsWith('id:')) { + continue; + } + + const jsonData = JSON.parse(data); + onData?.(jsonData); + } catch (error) { + // Handle non-JSON lines + if (line.trim() !== '') { + onData?.(line.trim()); + } + } + } + } + } + } catch (error) { + onError?.(error); + } finally { + reader.releaseLock(); + } +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/constants/datasourceConstants.ts b/client/packages/lowcoder/src/constants/datasourceConstants.ts index 0c65449f38..31094d43df 100644 --- a/client/packages/lowcoder/src/constants/datasourceConstants.ts +++ b/client/packages/lowcoder/src/constants/datasourceConstants.ts @@ -45,3 +45,4 @@ export const QUICK_REST_API_ID = "#QUICK_REST_API"; export const QUICK_GRAPHQL_ID = "#QUICK_GRAPHQL"; export const JS_CODE_ID = "#JS_CODE"; export const OLD_LOWCODER_DATASOURCE: Partial[] = []; +export const QUICK_SSE_HTTP_API_ID = "#QUICK_REST_API"; diff --git a/client/packages/lowcoder/src/constants/queryConstants.ts b/client/packages/lowcoder/src/constants/queryConstants.ts index be78de0d6e..06de2507c2 100644 --- a/client/packages/lowcoder/src/constants/queryConstants.ts +++ b/client/packages/lowcoder/src/constants/queryConstants.ts @@ -14,12 +14,14 @@ import { toPluginQuery } from "comps/queries/pluginQuery/pluginQuery"; import { MultiCompConstructor } from "lowcoder-core"; import { DataSourcePluginMeta } from "lowcoder-sdk/dataSource"; import { AlaSqlQuery } from "@lowcoder-ee/comps/queries/httpQuery/alasqlQuery"; +import { SseHttpQuery } from "@lowcoder-ee/comps/queries/httpQuery/sseHttpQuery"; export type DatasourceType = | "mysql" | "mongodb" | "restApi" | "streamApi" + | "sseHttpApi" | "postgres" | "redis" | "es" @@ -41,6 +43,7 @@ export const QueryMap = { alasql: AlaSqlQuery, restApi: HttpQuery, streamApi: StreamQuery, + sseHttpApi: SseHttpQuery, mongodb: MongoQuery, postgres: SQLQuery, redis: RedisQuery, diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 43bcb39868..05ab251a06 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -744,6 +744,7 @@ export const en = { "transformer": "Transformer", "quickRestAPI": "REST Query", "quickStreamAPI": "Stream Query", + "quickSseHttpAPI": "SSE HTTP Stream Query", "quickGraphql": "GraphQL Query", "quickAlasql": "Local SQL Query", "databaseType": "Database Type", @@ -1412,12 +1413,77 @@ export const en = { "timerCompDesc": "A component that displays a countdown or elapsed time, useful for tracking durations and deadlines.", "timerCompKeywords": "timer, countdown, elapsed time, tracking, durations, deadlines", + "chatCompName": "AI Chat", + "chatCompDesc": "An interactive chat component for AI conversations with support for multiple message handlers and streaming responses.", + "chatCompKeywords": "chat, ai, conversation, assistant, messaging, streaming", }, - + + "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)", + + // Default Values & Placeholders + "defaultSystemPrompt": "You are a helpful assistant.", + "defaultPlaceholder": "Type your message here...", + "composerPlaceholder": "Write a message...", + "defaultErrorMessage": "Sorry, I encountered an error. Please try again.", + "newChatTitle": "New Chat", + "placeholderLabel": "Placeholder", + "placeholderTooltip": "Placeholder text for the composer input", + "newThread": "New Thread", + "welcomeMessage": "How can I help you today?", + "suggestionWeather": "What's the weather in Tokyo?", + "suggestionAssistant": "What's the news today?", + + + + // 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", + "database": "Database", + + // Event Labels & Descriptions + "componentLoad": "Component Load", + "componentLoadDesc": "Triggered when the chat component finishes loading - Load existing data from backend", + "messageSent": "Message Sent", + "messageSentDesc": "Triggered when a user sends a message - Auto-save user messages", + "messageReceived": "Message Received", + "messageReceivedDesc": "Triggered when a response is received from the AI - Auto-save AI responses", + "threadCreated": "Thread Created", + "threadCreatedDesc": "Triggered when a new thread is created - Auto-save new threads", + "threadUpdated": "Thread Updated", + "threadUpdatedDesc": "Triggered when a thread is updated - Auto-save thread changes", + "threadDeleted": "Thread Deleted", + "threadDeletedDesc": "Triggered when a thread is deleted - Delete thread from backend", + + // Exposed Variables (for documentation) + "currentMessage": "Current user message", + "conversationHistory": "Full conversation history as JSON array", + "databaseNameExposed": "Database name for SQL queries (ChatDB_)" + }, // eighth part - "comp": { "menuViewDocs": "View Documentation", "menuViewPlayground": "View interactive Playground", diff --git a/client/packages/lowcoder/src/pages/editor/bottom/BottomPanel.tsx b/client/packages/lowcoder/src/pages/editor/bottom/BottomPanel.tsx index d074696f19..0ca02f6b23 100644 --- a/client/packages/lowcoder/src/pages/editor/bottom/BottomPanel.tsx +++ b/client/packages/lowcoder/src/pages/editor/bottom/BottomPanel.tsx @@ -1,145 +1,149 @@ -import { BottomContent } from "pages/editor/bottom/BottomContent"; -import { ResizableBox, ResizeCallbackData } from "react-resizable"; -import styled from "styled-components"; -import * as React from "react"; -import { useMemo, useState } from "react"; -import { getPanelStyle, savePanelStyle } from "util/localStorageUtil"; -import { BottomResultPanel } from "../../../components/resultPanel/BottomResultPanel"; -import { AppState } from "../../../redux/reducers"; -import { getUser } from "../../../redux/selectors/usersSelectors"; -import { connect } from "react-redux"; -import { Layers } from "constants/Layers"; -import Flex from "antd/es/flex"; -import type { MenuProps } from 'antd/es/menu'; -import { BuildOutlined, DatabaseOutlined } from "@ant-design/icons"; -import Menu from "antd/es/menu/menu"; -import { ChatView } from "@lowcoder-ee/comps/comps/chatComp/chatView"; -import { AIGenerate } from "lowcoder-design"; - -type MenuItem = Required['items'][number]; - -const StyledResizableBox = styled(ResizableBox)` - position: relative; - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); - border-top: 1px solid #e1e3eb; - z-index: ${Layers.bottomPanel}; - - .react-resizable-handle { - position: absolute; - border-top: transparent solid 3px; - width: 100%; - padding: 0 3px 3px 0; - top: 0; - cursor: row-resize; - } -`; - -const StyledMenu = styled(Menu)` - width: 40px; - padding: 6px 0; - - .ant-menu-item { - height: 30px; - line-height: 30px; - } -`; - -const ChatHeader = styled.div` - flex: 0 0 35px; - padding: 0 16px; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid #e1e3eb; - background: #fafafa; -`; -const ChatTitle = styled.h3` - margin: 0; - font-size: 14px; - font-weight: 500; - color: #222222; -`; - -const preventDefault = (e: any) => { - e.preventDefault(); -}; - -// prevent the editor window slide when resize -const addListener = () => { - window.addEventListener("mousedown", preventDefault); -}; - -const removeListener = () => { - window.removeEventListener("mousedown", preventDefault); -}; - -function Bottom(props: any) { - const panelStyle = useMemo(() => getPanelStyle(), []); - const clientHeight = document.documentElement.clientHeight; - const resizeStop = (e: React.SyntheticEvent, data: ResizeCallbackData) => { - savePanelStyle({ ...panelStyle, bottom: { h: data.size.height } }); - setBottomHeight(data.size.height); - removeListener(); - }; - - const [bottomHeight, setBottomHeight] = useState(panelStyle.bottom.h); - const [currentOption, setCurrentOption] = useState("data"); - - const items: MenuItem[] = [ - { key: 'data', icon: , label: 'Data Queries' }, - { key: 'ai', icon: , label: 'Lowcoder AI' }, - ]; - - return ( - <> - - - - { - setCurrentOption(key); - }} - /> - { currentOption === "data" && } - { currentOption === "ai" && ( - - - Lowcoder AI Assistant - - - - )} - - - - ); -} - -const mapStateToProps = (state: AppState) => { - return { - orgId: getUser(state).currentOrgId, - datasourceInfos: state.entities.datasource.data, - }; -}; - -export default connect(mapStateToProps, null)(Bottom); +import { BottomContent } from "pages/editor/bottom/BottomContent"; +import { ResizableBox, ResizeCallbackData } from "react-resizable"; +import styled from "styled-components"; +import * as React from "react"; +import { useMemo, useState } from "react"; +import { getPanelStyle, savePanelStyle } from "util/localStorageUtil"; +import { BottomResultPanel } from "../../../components/resultPanel/BottomResultPanel"; +import { AppState } from "../../../redux/reducers"; +import { getUser } from "../../../redux/selectors/usersSelectors"; +import { connect } from "react-redux"; +import { Layers } from "constants/Layers"; +import Flex from "antd/es/flex"; +import type { MenuProps } from 'antd/es/menu'; +import { BuildOutlined, DatabaseOutlined } from "@ant-design/icons"; +import Menu from "antd/es/menu/menu"; +import { AIGenerate } from "lowcoder-design"; +import { ChatPanel } from "@lowcoder-ee/comps/comps/chatComp/components/ChatPanel"; + +type MenuItem = Required['items'][number]; + +const StyledResizableBox = styled(ResizableBox)` + position: relative; + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); + border-top: 1px solid #e1e3eb; + z-index: ${Layers.bottomPanel}; + + .react-resizable-handle { + position: absolute; + border-top: transparent solid 3px; + width: 100%; + padding: 0 3px 3px 0; + top: 0; + cursor: row-resize; + } +`; + +const StyledMenu = styled(Menu)` + width: 40px; + padding: 6px 0; + + .ant-menu-item { + height: 30px; + line-height: 30px; + } +`; + +const ChatHeader = styled.div` + flex: 0 0 35px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #e1e3eb; + background: #fafafa; +`; +const ChatTitle = styled.h3` + margin: 0; + font-size: 14px; + font-weight: 500; + color: #222222; +`; + +const preventDefault = (e: any) => { + e.preventDefault(); +}; + +// prevent the editor window slide when resize +const addListener = () => { + window.addEventListener("mousedown", preventDefault); +}; + +const removeListener = () => { + window.removeEventListener("mousedown", preventDefault); +}; + +function Bottom(props: any) { + const panelStyle = useMemo(() => getPanelStyle(), []); + const clientHeight = document.documentElement.clientHeight; + const resizeStop = (e: React.SyntheticEvent, data: ResizeCallbackData) => { + savePanelStyle({ ...panelStyle, bottom: { h: data.size.height } }); + setBottomHeight(data.size.height); + removeListener(); + }; + + const [bottomHeight, setBottomHeight] = useState(panelStyle.bottom.h); + const [currentOption, setCurrentOption] = useState("data"); + + const items: MenuItem[] = [ + { key: 'data', icon: , label: 'Data Queries' }, + { key: 'ai', icon: , label: 'Lowcoder AI' }, + ]; + + return ( + <> + + + + { + setCurrentOption(key); + }} + /> + { currentOption === "data" && } + { currentOption === "ai" && ( + + + Lowcoder AI Assistant + + {/* */} + + + )} + + + + ); +} + +const mapStateToProps = (state: AppState) => { + return { + orgId: getUser(state).currentOrgId, + datasourceInfos: state.entities.datasource.data, + }; +}; + +export default connect(mapStateToProps, null)(Bottom); diff --git a/client/packages/lowcoder/src/util/bottomResUtils.tsx b/client/packages/lowcoder/src/util/bottomResUtils.tsx index b2f2baf425..78c5a4de3e 100644 --- a/client/packages/lowcoder/src/util/bottomResUtils.tsx +++ b/client/packages/lowcoder/src/util/bottomResUtils.tsx @@ -110,6 +110,8 @@ export const getBottomResIcon = ( return ; case "streamApi": return ; + case "sseHttpApi": + return ; case "alasql": return ; case "restApi": diff --git a/client/yarn.lock b/client/yarn.lock index e8357b3c08..10f5dafee8 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3401,6 +3401,29 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-avatar@npm:^1.1.10": + version: 1.1.10 + resolution: "@radix-ui/react-avatar@npm:1.1.10" + dependencies: + "@radix-ui/react-context": 1.1.2 + "@radix-ui/react-primitive": 2.1.3 + "@radix-ui/react-use-callback-ref": 1.1.1 + "@radix-ui/react-use-is-hydrated": 0.1.0 + "@radix-ui/react-use-layout-effect": 1.1.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 3d63c9b99549c574be0f3f24028ab3f339e51ca85fc0821887f83e30af1342a41b3a3f40bf0fc12cdb2814340342530b4aba6b758deda9e99f6846b41d2f987f + languageName: node + linkType: hard + "@radix-ui/react-compose-refs@npm:1.1.2, @radix-ui/react-compose-refs@npm:^1.1.2": version: 1.1.2 resolution: "@radix-ui/react-compose-refs@npm:1.1.2" @@ -3427,6 +3450,38 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-dialog@npm:^1.1.14": + version: 1.1.14 + resolution: "@radix-ui/react-dialog@npm:1.1.14" + dependencies: + "@radix-ui/primitive": 1.1.2 + "@radix-ui/react-compose-refs": 1.1.2 + "@radix-ui/react-context": 1.1.2 + "@radix-ui/react-dismissable-layer": 1.1.10 + "@radix-ui/react-focus-guards": 1.1.2 + "@radix-ui/react-focus-scope": 1.1.7 + "@radix-ui/react-id": 1.1.1 + "@radix-ui/react-portal": 1.1.9 + "@radix-ui/react-presence": 1.1.4 + "@radix-ui/react-primitive": 2.1.3 + "@radix-ui/react-slot": 1.2.3 + "@radix-ui/react-use-controllable-state": 1.2.2 + aria-hidden: ^1.2.4 + react-remove-scroll: ^2.6.3 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 4928c0bf84b3a054eb3b4659b8e87192d8c120333d8437fcbd9d9311502d5eea9e9c87173929d4bfbc0db61b1134fcd98015756011d67ddcd2aed1b4a0134d7c + languageName: node + linkType: hard + "@radix-ui/react-dismissable-layer@npm:1.1.10": version: 1.1.10 resolution: "@radix-ui/react-dismissable-layer@npm:1.1.10" @@ -3723,6 +3778,21 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-is-hydrated@npm:0.1.0": + version: 0.1.0 + resolution: "@radix-ui/react-use-is-hydrated@npm:0.1.0" + dependencies: + use-sync-external-store: ^1.5.0 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 72e68a85a7a4a6dafd255a0cc87b6410bf0356c5e296e2eb82c265559408a735204cd150408b9c0d598057dafad3d51086e0362633bd728e95655b3bfd70ae26 + languageName: node + linkType: hard + "@radix-ui/react-use-layout-effect@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1" @@ -14877,6 +14947,8 @@ coolshapes-react@lowcoder-org/coolshapes-react: "@jsonforms/core": ^3.5.1 "@lottiefiles/dotlottie-react": ^0.13.0 "@manaflair/redux-batch": ^1.0.0 + "@radix-ui/react-avatar": ^1.1.10 + "@radix-ui/react-dialog": ^1.1.14 "@radix-ui/react-slot": ^1.2.3 "@radix-ui/react-tooltip": ^1.2.7 "@rjsf/antd": ^5.24.9 @@ -22428,7 +22500,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard -"use-sync-external-store@npm:^1.2.0, use-sync-external-store@npm:^1.4.0": +"use-sync-external-store@npm:^1.2.0, use-sync-external-store@npm:^1.4.0, use-sync-external-store@npm:^1.5.0": version: 1.5.0 resolution: "use-sync-external-store@npm:1.5.0" peerDependencies: pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy