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..ac32527bf8 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -1,46 +1,117 @@ -// 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 { 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 { ChatCore } from "./components/ChatCore"; +import { ChatPropertyView } from "./chatPropertyView"; +import { createChatStorage } from "./utils/storageFactory"; +import { QueryHandler, createMessageHandler } from "./handlers/messageHandlers"; +import { useMemo } from "react"; +import { changeChildAction } from "lowcoder-core"; + +import "@assistant-ui/styles/index.css"; +import "@assistant-ui/styles/markdown.css"; + +// ============================================================================ +// SIMPLIFIED CHILDREN MAP - ONLY ESSENTIAL PROPS +// ============================================================================ + +const ModelTypeOptions = [ + { label: "Query", value: "query" }, + { label: "N8N Workflow", value: "n8n" }, +] as const; + +export const chatChildrenMap = { + // Storage + tableName: withDefault(StringControl, "default"), + + // 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, "You are a helpful assistant."), + streaming: BoolControl.DEFAULT_TRUE, + + // UI Configuration + placeholder: withDefault(StringControl, "Chat Component"), + + // Exposed Variables (not shown in Property View) + currentMessage: stringExposingStateControl("currentMessage", ""), +}; + +// ============================================================================ +// CLEAN CHATCOMP - USES NEW ARCHITECTURE +// ============================================================================ + +const ChatTmpComp = new UICompBuilder( + chatChildrenMap, + (props, dispatch) => { + // Create storage from tableName + const storage = useMemo(() => + createChatStorage(props.tableName), + [props.tableName] + ); + + // 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)); + }; + + return ( + + ); + } +) +.setPropertyViewFn((children) => ) +.build(); + +// ============================================================================ +// EXPORT WITH EXPOSED VARIABLES +// ============================================================================ + +export const ChatComp = withExposingConfigs(ChatTmpComp, [ + new NameConfig("currentMessage", "Current user message"), ]); \ 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..ecb130c442 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; - -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 = { - 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 +// client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts + +// ============================================================================ +// CLEAN CHATCOMP TYPES - SIMPLIFIED AND FOCUSED +// ============================================================================ + +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 +}; + +// Legacy export for backwards compatibility (if needed) +export type ChatCompLegacyProps = ChatCompProps; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx index 2a9143c4ae..784ef44b56 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx @@ -1,35 +1,69 @@ -// 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 from "react"; +import { Section, sectionNames } from "lowcoder-design"; + +// ============================================================================ +// CLEAN PROPERTY VIEW - FOCUSED ON ESSENTIAL CONFIGURATION +// ============================================================================ + +export const ChatPropertyView = React.memo((props: any) => { + const { children } = props; + + return ( + <> + {/* Basic Configuration */} +
+ {children.placeholder.propertyView({ + label: "Placeholder Text", + placeholder: "Enter placeholder text..." + })} + + {children.tableName.propertyView({ + label: "Storage Table", + placeholder: "default", + tooltip: "Storage identifier - use same value to share conversations between components" + })} +
+ + {/* Message Handler Configuration */} +
+ {children.handlerType.propertyView({ + label: "Handler Type", + tooltip: "How messages are processed" + })} + + {/* Show chatQuery field only for "query" handler */} + {children.handlerType.value === "query" && ( + children.chatQuery.propertyView({ + label: "Chat Query", + placeholder: "Select a query to handle messages" + }) + )} + + {/* Show modelHost field only for "n8n" handler */} + {children.handlerType.value === "n8n" && ( + children.modelHost.propertyView({ + label: "N8N Webhook URL", + placeholder: "http://localhost:5678/webhook/...", + tooltip: "N8N webhook endpoint for processing messages" + }) + )} + + {children.systemPrompt.propertyView({ + label: "System Prompt", + placeholder: "You are a helpful assistant...", + tooltip: "Initial instructions for the AI" + })} + + {children.streaming.propertyView({ + label: "Enable Streaming", + tooltip: "Stream responses in real-time (when supported)" + })} +
+ + + ); +}); + 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..c40151dd5b --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx @@ -0,0 +1,21 @@ +// 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, onMessageUpdate }: 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 71% 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 14ba061ca7..8c41b1565f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx @@ -1,255 +1,246 @@ -import React, { useState } 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"; - -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, -}) => { - const { text, modelHost, modelType } = params; - - let url = modelHost; - if (modelType === "direct-llm") { - url = `${modelHost}/api/chat/completions`; - } - - // 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); - - 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 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!, - }); - - const assistantMessage: MyMessage = { - 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: 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!, - }); - - const assistantMessage: MyMessage = { - 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: 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 } 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"; + +// ============================================================================ +// 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; + onMessageUpdate?: (message: string) => void; +} + +const generateId = () => Math.random().toString(36).substr(2, 9); + +export function ChatCoreMain({ messageHandler, onMessageUpdate }: ChatCoreMainProps) { + const { state, actions } = useChatContext(); + const [isRunning, setIsRunning] = useState(false); + + console.log("CHAT CORE STATE", state); + + // Get messages for current thread + const currentMessages = actions.getCurrentMessages(); + + // 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: `Sorry, I encountered an error: ${error instanceof Error ? error.message : 'Unknown error'}`, + 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: `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 (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("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 ( + + + + + + + ); +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx new file mode 100644 index 0000000000..a36c1f38ec --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx @@ -0,0 +1,47 @@ +// 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 "@assistant-ui/styles/index.css"; +import "@assistant-ui/styles/markdown.css"; + +// ============================================================================ +// CHAT PANEL - CLEAN BOTTOM PANEL COMPONENT +// ============================================================================ + +export function ChatPanel({ + tableName, + modelHost, + systemPrompt = "You are a helpful assistant.", + 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/context/ChatContext.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx index 41ef892af4..65670edff8 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,385 @@ -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"; + +// ============================================================================ +// 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: "New Chat" }], + 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: "New Chat", + 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 = "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: 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) => { + // Update local state first + dispatch({ type: "DELETE_THREAD", threadId }); + + // Delete from storage + try { + await storage.deleteThread(threadId); + dispatch({ type: "MARK_SAVED" }); + } 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/handlers/messageHandlers.ts b/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts new file mode 100644 index 0000000000..14dcf5a718 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts @@ -0,0 +1,133 @@ +// 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 the user prompt as variable named 'prompt' by default + args: { prompt: { value: message } }, + }) + ) + ); + + // Extract reply text from the query result (same logic as your current implementation) + let content: string; + if (typeof result === "string") { + content = result; + } else if (result && typeof result === "object") { + content = + (result as any).response ?? + (result as any).message ?? + (result as any).content ?? + JSON.stringify(result); + } else { + content = String(result); + } + + return { content }; + } 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..f820d55eee --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -0,0 +1,86 @@ +// 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; + } + + // ============================================================================ + // 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; + } + + // ============================================================================ + // COMPONENT PROPS (what each component actually needs) + // ============================================================================ + + export interface ChatCoreProps { + storage: ChatStorage; + messageHandler: MessageHandler; + onMessageUpdate?: (message: 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..8e62e42744 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts @@ -0,0 +1,181 @@ +// 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 = `${tableName}_threads`; + const messagesTable = `${tableName}_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}`); + await alasql.promise(`USE ${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 + ) + `); + + console.log(`✅ Chat database initialized: ${dbName}`); + } 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(); + console.log(`✅ Database reset and reinitialized: ${dbName}`); + } catch (error) { + console.error("Failed to reset database:", error); + throw error; + } + } + }; +} \ No newline at end of file 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..b897add3f7 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", diff --git a/client/packages/lowcoder/src/pages/editor/bottom/BottomPanel.tsx b/client/packages/lowcoder/src/pages/editor/bottom/BottomPanel.tsx index c71364470b..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":