From d318b7e8aba0f2607114527538e0876b8f414865 Mon Sep 17 00:00:00 2001 From: FARAN Date: Thu, 24 Jul 2025 00:50:01 +0500 Subject: [PATCH 1/5] add file attachments support for chat component --- .../src/comps/comps/chatComp/chatComp.tsx | 36 +- .../comps/chatComp/components/ChatCore.tsx | 15 +- .../chatComp/components/ChatCoreMain.tsx | 581 ++++++++++-------- .../components/assistant-ui/thread.tsx | 3 +- .../chatComp/components/ui/attachment.tsx | 60 +- .../chatComp/handlers/messageHandlers.ts | 20 +- .../comps/comps/chatComp/types/chatTypes.ts | 13 +- .../comps/chatComp/utils/attachmentAdapter.ts | 93 +++ .../comps/chatComp/utils/storageFactory.ts | 23 +- 9 files changed, 518 insertions(+), 326 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index 0091ed6ab..57ac9040a 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -80,13 +80,37 @@ export const ChatEventHandlerControl = eventHandlerControl(ChatEventOptions); export function addSystemPromptToHistory( conversationHistory: ChatMessage[], systemPrompt: string -): Array<{ role: string; content: string; timestamp: number }> { +): Array<{ role: string; content: string; timestamp: number; attachments?: any[] }> { // Format conversation history for use in queries - const formattedHistory = conversationHistory.map(msg => ({ - role: msg.role, - content: msg.text, - timestamp: msg.timestamp - })); + const formattedHistory = conversationHistory.map(msg => { + const baseMessage = { + role: msg.role, + content: msg.text, + timestamp: msg.timestamp + }; + + // Include attachment metadata if present (for API calls and external integrations) + if (msg.attachments && msg.attachments.length > 0) { + return { + ...baseMessage, + attachments: msg.attachments.map(att => ({ + id: att.id, + type: att.type, + name: att.name, + contentType: att.contentType, + // Include content for images (base64 data URLs are useful for APIs) + ...(att.type === "image" && att.content && { + content: att.content.map(c => ({ + type: c.type, + ...(c.type === "image" && { image: c.image }) + })) + }) + })) + }; + } + + return baseMessage; + }); // Create system message (always exists since we have default) const systemMessage = [{ diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx index af867b7f5..ad0d33e2c 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx @@ -4,6 +4,7 @@ import React from "react"; import { ChatProvider } from "./context/ChatContext"; import { ChatCoreMain } from "./ChatCoreMain"; import { ChatCoreProps } from "../types/chatTypes"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; // ============================================================================ // CHAT CORE - THE SHARED FOUNDATION @@ -18,14 +19,16 @@ export function ChatCore({ onEvent }: ChatCoreProps) { return ( - - + + - + onEvent={onEvent} + /> + + ); } \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx index 4bc7363b9..ca6a9fa54 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx @@ -1,270 +1,311 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx - -import React, { useState, useEffect } from "react"; -import { - useExternalStoreRuntime, - ThreadMessageLike, - AppendMessage, - AssistantRuntimeProvider, - ExternalStoreThreadListAdapter, -} from "@assistant-ui/react"; -import { Thread } from "./assistant-ui/thread"; -import { ThreadList } from "./assistant-ui/thread-list"; -import { - useChatContext, - ChatMessage, - RegularThreadData, - ArchivedThreadData -} from "./context/ChatContext"; -import { MessageHandler } from "../types/chatTypes"; -import styled from "styled-components"; -import { trans } from "i18n"; - -// ============================================================================ -// STYLED COMPONENTS (same as your current ChatMain) -// ============================================================================ - -const ChatContainer = styled.div` - display: flex; - height: 500px; - - p { - margin: 0; - } - - .aui-thread-list-root { - width: 250px; - background-color: #fff; - padding: 10px; - } - - .aui-thread-root { - flex: 1; - background-color: #f9fafb; - } - - .aui-thread-list-item { - cursor: pointer; - transition: background-color 0.2s ease; - - &[data-active="true"] { - background-color: #dbeafe; - border: 1px solid #bfdbfe; - } - } -`; - -// ============================================================================ -// CHAT CORE MAIN - CLEAN PROPS, FOCUSED RESPONSIBILITY -// ============================================================================ - -interface ChatCoreMainProps { - messageHandler: MessageHandler; - placeholder?: string; - onMessageUpdate?: (message: string) => void; - onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; - // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK (OPTIONAL) - onEvent?: (eventName: string) => void; -} - -const generateId = () => Math.random().toString(36).substr(2, 9); - -export function ChatCoreMain({ - messageHandler, - placeholder, - onMessageUpdate, - onConversationUpdate, - onEvent -}: ChatCoreMainProps) { - const { state, actions } = useChatContext(); - const [isRunning, setIsRunning] = useState(false); - - // Get messages for current thread - const currentMessages = actions.getCurrentMessages(); - - // Notify parent component of conversation changes - useEffect(() => { - onConversationUpdate?.(currentMessages); - }, [currentMessages]); - - // Trigger component load event on mount - useEffect(() => { - onEvent?.("componentLoad"); - }, [onEvent]); - - // Convert custom format to ThreadMessageLike (same as your current implementation) - const convertMessage = (message: ChatMessage): ThreadMessageLike => ({ - role: message.role, - content: [{ type: "text", text: message.text }], - id: message.id, - createdAt: new Date(message.timestamp), - }); - - // Handle new message - MUCH CLEANER with messageHandler - const onNew = async (message: AppendMessage) => { - // Extract text from AppendMessage content array - if (message.content.length !== 1 || message.content[0]?.type !== "text") { - throw new Error("Only text content is supported"); - } - - // Add user message in custom format - const userMessage: ChatMessage = { - id: generateId(), - role: "user", - text: message.content[0].text, - timestamp: Date.now(), - }; - - // Update currentMessage state to expose to queries - onMessageUpdate?.(userMessage.text); - - // Update current thread with new user message - await actions.addMessage(state.currentThreadId, userMessage); - setIsRunning(true); - - try { - - // Use the message handler (no more complex logic here!) - const response = await messageHandler.sendMessage(userMessage.text); - - const assistantMessage: ChatMessage = { - id: generateId(), - role: "assistant", - text: response.content, - timestamp: Date.now(), - }; - - // Update current thread with assistant response - await actions.addMessage(state.currentThreadId, assistantMessage); - } catch (error) { - // Handle errors gracefully - const errorMessage: ChatMessage = { - id: generateId(), - role: "assistant", - text: trans("chat.errorUnknown"), - timestamp: Date.now(), - }; - - await actions.addMessage(state.currentThreadId, errorMessage); - } finally { - setIsRunning(false); - } - }; - - // Handle edit message - CLEANER with messageHandler - const onEdit = async (message: AppendMessage) => { - // Extract text from AppendMessage content array - if (message.content.length !== 1 || message.content[0]?.type !== "text") { - throw new Error("Only text content is supported"); - } - - // Find the index where to insert the edited message - const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; - - // Keep messages up to the parent - const newMessages = [...currentMessages.slice(0, index)]; - - // Add the edited message in custom format - const editedMessage: ChatMessage = { - id: generateId(), - role: "user", - text: message.content[0].text, - timestamp: Date.now(), - }; - newMessages.push(editedMessage); - - // Update currentMessage state to expose to queries - onMessageUpdate?.(editedMessage.text); - - // Update messages using the new context action - await actions.updateMessages(state.currentThreadId, newMessages); - setIsRunning(true); - - try { - // Use the message handler (clean!) - const response = await messageHandler.sendMessage(editedMessage.text); - - const assistantMessage: ChatMessage = { - id: generateId(), - role: "assistant", - text: response.content, - timestamp: Date.now(), - }; - - newMessages.push(assistantMessage); - await actions.updateMessages(state.currentThreadId, newMessages); - } catch (error) { - // Handle errors gracefully - const errorMessage: ChatMessage = { - id: generateId(), - role: "assistant", - text: trans("chat.errorUnknown"), - timestamp: Date.now(), - }; - - newMessages.push(errorMessage); - await actions.updateMessages(state.currentThreadId, newMessages); - } finally { - setIsRunning(false); - } - }; - - // Thread list adapter for managing multiple threads (same as your current implementation) - const threadListAdapter: ExternalStoreThreadListAdapter = { - threadId: state.currentThreadId, - threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"), - archivedThreads: state.threadList.filter((t): t is ArchivedThreadData => t.status === "archived"), - - onSwitchToNewThread: async () => { - const threadId = await actions.createThread(trans("chat.newChatTitle")); - actions.setCurrentThread(threadId); - onEvent?.("threadCreated"); - }, - - onSwitchToThread: (threadId) => { - actions.setCurrentThread(threadId); - }, - - onRename: async (threadId, newTitle) => { - await actions.updateThread(threadId, { title: newTitle }); - onEvent?.("threadUpdated"); - }, - - onArchive: async (threadId) => { - await actions.updateThread(threadId, { status: "archived" }); - onEvent?.("threadUpdated"); - }, - - onDelete: async (threadId) => { - await actions.deleteThread(threadId); - onEvent?.("threadDeleted"); - }, - }; - - const runtime = useExternalStoreRuntime({ - messages: currentMessages, - setMessages: (messages) => { - actions.updateMessages(state.currentThreadId, messages); - }, - convertMessage, - isRunning, - onNew, - onEdit, - adapters: { - threadList: threadListAdapter, - }, - }); - - if (!state.isInitialized) { - return
Loading...
; - } - - return ( - - - - - - - ); -} +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx + +import React, { useState, useEffect } from "react"; +import { + useExternalStoreRuntime, + ThreadMessageLike, + AppendMessage, + AssistantRuntimeProvider, + ExternalStoreThreadListAdapter, + CompleteAttachment, + TextContentPart, + ThreadUserContentPart +} from "@assistant-ui/react"; +import { Thread } from "./assistant-ui/thread"; +import { ThreadList } from "./assistant-ui/thread-list"; +import { + useChatContext, + RegularThreadData, + ArchivedThreadData +} from "./context/ChatContext"; +import { MessageHandler, ChatMessage } from "../types/chatTypes"; +import styled from "styled-components"; +import { trans } from "i18n"; +import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; + +// ============================================================================ +// STYLED COMPONENTS (same as your current ChatMain) +// ============================================================================ + +const ChatContainer = styled.div` + display: flex; + height: 500px; + + p { + margin: 0; + } + + .aui-thread-list-root { + width: 250px; + background-color: #fff; + padding: 10px; + } + + .aui-thread-root { + flex: 1; + background-color: #f9fafb; + } + + .aui-thread-list-item { + cursor: pointer; + transition: background-color 0.2s ease; + + &[data-active="true"] { + background-color: #dbeafe; + border: 1px solid #bfdbfe; + } + } +`; + +// ============================================================================ +// CHAT CORE MAIN - CLEAN PROPS, FOCUSED RESPONSIBILITY +// ============================================================================ + +interface ChatCoreMainProps { + messageHandler: MessageHandler; + placeholder?: string; + onMessageUpdate?: (message: string) => void; + onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; + // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK (OPTIONAL) + onEvent?: (eventName: string) => void; +} + +const generateId = () => Math.random().toString(36).substr(2, 9); + +export function ChatCoreMain({ + messageHandler, + placeholder, + onMessageUpdate, + onConversationUpdate, + onEvent +}: ChatCoreMainProps) { + const { state, actions } = useChatContext(); + const [isRunning, setIsRunning] = useState(false); + + console.log("RENDERING CHAT CORE MAIN"); + + // Get messages for current thread + const currentMessages = actions.getCurrentMessages(); + + // Notify parent component of conversation changes + useEffect(() => { + onConversationUpdate?.(currentMessages); + }, [currentMessages]); + + // Trigger component load event on mount + useEffect(() => { + onEvent?.("componentLoad"); + }, [onEvent]); + + // Convert custom format to ThreadMessageLike (same as your current implementation) + const convertMessage = (message: ChatMessage): ThreadMessageLike => { + const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; + + // Add attachment content if attachments exist + if (message.attachments && message.attachments.length > 0) { + for (const attachment of message.attachments) { + if (attachment.content) { + content.push(...attachment.content); + } + } + } + + return { + role: message.role, + content, + id: message.id, + createdAt: new Date(message.timestamp), + ...(message.attachments && message.attachments.length > 0 && { attachments: message.attachments }), + }; + }; + + // Handle new message - MUCH CLEANER with messageHandler + const onNew = async (message: AppendMessage) => { + const textPart = (message.content as ThreadUserContentPart[]).find( + (part): part is TextContentPart => part.type === "text" + ); + + const text = textPart?.text?.trim() ?? ""; + + const completeAttachments = (message.attachments ?? []).filter( + (att): att is CompleteAttachment => att.status.type === "complete" + ); + + const hasText = text.length > 0; + const hasAttachments = completeAttachments.length > 0; + + if (!hasText && !hasAttachments) { + throw new Error("Cannot send an empty message"); + } + + const userMessage: ChatMessage = { + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + attachments: completeAttachments, + }; + + onMessageUpdate?.(text); + await actions.addMessage(state.currentThreadId, userMessage); + setIsRunning(true); + + try { + const response = await messageHandler.sendMessage(userMessage); // Send full message object with attachments + + const assistantMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }; + + await actions.addMessage(state.currentThreadId, assistantMessage); + } catch (error) { + const errorMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }; + + await actions.addMessage(state.currentThreadId, errorMessage); + } finally { + setIsRunning(false); + } + }; + + + // Handle edit message - CLEANER with messageHandler + const onEdit = async (message: AppendMessage) => { + // Extract the first text content part (if any) + const textPart = (message.content as ThreadUserContentPart[]).find( + (part): part is TextContentPart => part.type === "text" + ); + + const text = textPart?.text?.trim() ?? ""; + + // Filter only complete attachments + const completeAttachments = (message.attachments ?? []).filter( + (att): att is CompleteAttachment => att.status.type === "complete" + ); + + const hasText = text.length > 0; + const hasAttachments = completeAttachments.length > 0; + + if (!hasText && !hasAttachments) { + throw new Error("Cannot send an empty message"); + } + + // Find the index of the message being edited + const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; + + // Build a new messages array: messages up to and including the one being edited + const newMessages = [...currentMessages.slice(0, index)]; + + // Build the edited user message + const editedMessage: ChatMessage = { + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + attachments: completeAttachments, + }; + + newMessages.push(editedMessage); + + // Expose message update to parent + onMessageUpdate?.(editedMessage.text); + + // Update state with edited context + await actions.updateMessages(state.currentThreadId, newMessages); + setIsRunning(true); + + try { + const response = await messageHandler.sendMessage(editedMessage); // Send full message object with attachments + + const assistantMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }; + + newMessages.push(assistantMessage); + await actions.updateMessages(state.currentThreadId, newMessages); + } catch (error) { + const errorMessage: ChatMessage = { + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }; + + newMessages.push(errorMessage); + await actions.updateMessages(state.currentThreadId, newMessages); + } finally { + setIsRunning(false); + } + }; + + // Thread list adapter for managing multiple threads (same as your current implementation) + const threadListAdapter: ExternalStoreThreadListAdapter = { + threadId: state.currentThreadId, + threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"), + archivedThreads: state.threadList.filter((t): t is ArchivedThreadData => t.status === "archived"), + + onSwitchToNewThread: async () => { + const threadId = await actions.createThread(trans("chat.newChatTitle")); + actions.setCurrentThread(threadId); + onEvent?.("threadCreated"); + }, + + onSwitchToThread: (threadId) => { + actions.setCurrentThread(threadId); + }, + + onRename: async (threadId, newTitle) => { + await actions.updateThread(threadId, { title: newTitle }); + onEvent?.("threadUpdated"); + }, + + onArchive: async (threadId) => { + await actions.updateThread(threadId, { status: "archived" }); + onEvent?.("threadUpdated"); + }, + + onDelete: async (threadId) => { + await actions.deleteThread(threadId); + onEvent?.("threadDeleted"); + }, + }; + + const runtime = useExternalStoreRuntime({ + messages: currentMessages, + setMessages: (messages) => { + actions.updateMessages(state.currentThreadId, messages); + }, + convertMessage, + isRunning, + onNew, + onEdit, + adapters: { + threadList: threadListAdapter, + attachments: universalAttachmentAdapter, + }, + }); + + if (!state.isInitialized) { + return
Loading...
; + } + + return ( + + + + + + + ); +} + diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx index 4018cbe5d..616acc087 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx @@ -25,7 +25,7 @@ import { import { Spin, Flex } from "antd"; import { LoadingOutlined } from "@ant-design/icons"; import styled from "styled-components"; -import { ComposerAddAttachment, ComposerAttachments } from "../ui/attachment"; +import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } from "../ui/attachment"; const SimpleANTDLoader = () => { const antIcon = ; @@ -197,6 +197,7 @@ import { ComposerAddAttachment, ComposerAttachments } from "../ui/attachment"; return ( +
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx index 1e430e5b3..3da338682 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx @@ -1,6 +1,6 @@ "use client"; -import { PropsWithChildren, useEffect, useState, type FC } from "react"; +import { PropsWithChildren, useCallback, useEffect, useRef, useState, type FC } from "react"; import { CircleXIcon, FileIcon, PaperclipIcon } from "lucide-react"; import { AttachmentPrimitive, @@ -148,20 +148,17 @@ const ScreenReaderOnly = styled.span` // UTILITY HOOKS // ============================================================================ -// Simple replacement for useShallow (removes zustand dependency) -const useShallow = (selector: (state: any) => T): ((state: any) => T) => selector; const useFileSrc = (file: File | undefined) => { - const [src, setSrc] = useState(undefined); + const [src, setSrc] = useState(); + const lastFileRef = useRef(); useEffect(() => { - if (!file) { - setSrc(undefined); - return; - } + if (!file || file === lastFileRef.current) return; const objectUrl = URL.createObjectURL(file); setSrc(objectUrl); + lastFileRef.current = file; return () => { URL.revokeObjectURL(objectUrl); @@ -171,18 +168,47 @@ const useFileSrc = (file: File | undefined) => { return src; }; + + const useAttachmentSrc = () => { - const { file, src } = useAttachment( - useShallow((a): { file?: File; src?: string } => { - if (a.type !== "image") return {}; - if (a.file) return { file: a.file }; - const src = a.content?.filter((c: any) => c.type === "image")[0]?.image; - if (!src) return {}; - return { src }; - }) + const attachment = useAttachment( + useCallback((a: any) => { + if (a.type !== "image") return undefined; + return a; + }, []) ); - return useFileSrc(file) ?? src; + const [src, setSrc] = useState(); + const lastAttachmentRef = useRef(); + + useEffect(() => { + if (!attachment || attachment === lastAttachmentRef.current) return; + + // Handle new/pending attachments with File objects + if (attachment.file && attachment.file instanceof File) { + const objectUrl = URL.createObjectURL(attachment.file); + setSrc(objectUrl); + lastAttachmentRef.current = attachment; + + return () => { + URL.revokeObjectURL(objectUrl); + }; + } + + // Handle saved attachments with base64 data + const imageContent = attachment.content?.find((c: any) => c.type === "image"); + if (imageContent?.image) { + setSrc(imageContent.image); + lastAttachmentRef.current = attachment; + return; + } + + // If no valid source found, clear the src + setSrc(undefined); + lastAttachmentRef.current = attachment; + }, [attachment]); + + return src; }; // ============================================================================ diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts b/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts index 1d674d04e..26af71ddc 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts @@ -1,6 +1,6 @@ // client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts -import { MessageHandler, MessageResponse, N8NHandlerConfig, QueryHandlerConfig } from "../types/chatTypes"; +import { MessageHandler, MessageResponse, N8NHandlerConfig, QueryHandlerConfig, ChatMessage } from "../types/chatTypes"; import { CompAction, routeByNameAction, executeQueryAction } from "lowcoder-core"; import { getPromiseAfterDispatch } from "util/promiseUtils"; @@ -11,7 +11,7 @@ import { getPromiseAfterDispatch } from "util/promiseUtils"; export class N8NHandler implements MessageHandler { constructor(private config: N8NHandlerConfig) {} - async sendMessage(message: string): Promise { + async sendMessage(message: ChatMessage): Promise { const { modelHost, systemPrompt, streaming } = this.config; if (!modelHost) { @@ -25,7 +25,7 @@ export class N8NHandler implements MessageHandler { 'Content-Type': 'application/json', }, body: JSON.stringify({ - message, + message: message.text, systemPrompt: systemPrompt || "You are a helpful assistant.", streaming: streaming || false }) @@ -54,25 +54,25 @@ export class N8NHandler implements MessageHandler { export class QueryHandler implements MessageHandler { constructor(private config: QueryHandlerConfig) {} - async sendMessage(message: string): Promise { + async sendMessage(message: ChatMessage): 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 }; + return { content: "(mock) You typed: " + message.text }; } try { - const result: any = await getPromiseAfterDispatch( dispatch, routeByNameAction( chatQuery, executeQueryAction({ - // Send both individual prompt and full conversation history + // Pass the full message object so attachments are available in queries args: { - prompt: { value: message }, + message: { value: message }, // Full ChatMessage object with attachments + prompt: { value: message.text }, // Keep backward compatibility }, }) ) @@ -92,9 +92,9 @@ export class QueryHandler implements MessageHandler { export class MockHandler implements MessageHandler { constructor(private delay: number = 1000) {} - async sendMessage(message: string): Promise { + async sendMessage(message: ChatMessage): Promise { await new Promise(resolve => setTimeout(resolve, this.delay)); - return { content: `Mock response: ${message}` }; + return { content: `Mock response: ${message.text}` }; } } diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index 25595b44d..1aaf4d611 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -1,14 +1,11 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts - -// ============================================================================ -// CORE MESSAGE AND THREAD TYPES (cleaned up from your existing types) -// ============================================================================ +import { CompleteAttachment } from "@assistant-ui/react"; export interface ChatMessage { id: string; role: "user" | "assistant"; text: string; timestamp: number; + attachments?: CompleteAttachment[]; } export interface ChatThread { @@ -43,8 +40,8 @@ export interface ChatMessage { // ============================================================================ export interface MessageHandler { - sendMessage(message: string): Promise; - // Future: sendMessageStream?(message: string): AsyncGenerator; + sendMessage(message: ChatMessage): Promise; + // Future: sendMessageStream?(message: ChatMessage): AsyncGenerator; } export interface MessageResponse { @@ -89,4 +86,4 @@ export interface ChatMessage { 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/attachmentAdapter.ts b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts new file mode 100644 index 000000000..a0f7c78e0 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts @@ -0,0 +1,93 @@ +import type { + AttachmentAdapter, + PendingAttachment, + CompleteAttachment, + Attachment, + ThreadUserContentPart + } from "@assistant-ui/react"; + + export const universalAttachmentAdapter: AttachmentAdapter = { + accept: "*/*", + + async add({ file }): Promise { + const MAX_SIZE = 10 * 1024 * 1024; + + if (file.size > MAX_SIZE) { + return { + id: crypto.randomUUID(), + type: getAttachmentType(file.type), + name: file.name, + file, + contentType: file.type, + status: { + type: "incomplete", + reason: "error" + } + }; + } + + return { + id: crypto.randomUUID(), + type: getAttachmentType(file.type), + name: file.name, + file, + contentType: file.type, + status: { + type: "running", + reason: "uploading", + progress: 0 + } + }; + }, + + async send(attachment: PendingAttachment): Promise { + const isImage = attachment.contentType.startsWith("image/"); + + const content: ThreadUserContentPart[] = isImage + ? [{ + type: "image", + image: await fileToBase64(attachment.file) + }] + : [{ + type: "file", + data: URL.createObjectURL(attachment.file), + mimeType: attachment.file.type + }]; + + return { + ...attachment, + content, + status: { + type: "complete" + } + }; + }, + + async remove(attachment: Attachment): Promise { + if (!attachment.content) return; + + for (const part of attachment.content) { + if (part.type === "file" && part.data.startsWith("blob:")) { + try { + URL.revokeObjectURL(part.data); + } catch { + // Ignore errors + } + } + } + } + }; + + function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + function getAttachmentType(mime: string): "image" | "file" { + return mime.startsWith("image/") ? "image" : "file"; + } + \ 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 index cc563ba66..c641dbbef 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts @@ -37,7 +37,8 @@ export function createChatStorage(tableName: string): ChatStorage { threadId STRING, role STRING, text STRING, - timestamp NUMBER + timestamp NUMBER, + attachments STRING ) `); @@ -104,8 +105,8 @@ export function createChatStorage(tableName: string): ChatStorage { 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]); + INSERT INTO ${messagesTable} VALUES (?, ?, ?, ?, ?, ?) + `, [message.id, threadId, message.role, message.text, message.timestamp, JSON.stringify(message.attachments || [])]); } catch (error) { console.error("Failed to save message:", error); throw error; @@ -120,8 +121,8 @@ export function createChatStorage(tableName: string): ChatStorage { // Insert all messages for (const message of messages) { await alasql.promise(` - INSERT INTO ${messagesTable} VALUES (?, ?, ?, ?, ?) - `, [message.id, threadId, message.role, message.text, message.timestamp]); + INSERT INTO ${messagesTable} VALUES (?, ?, ?, ?, ?, ?) + `, [message.id, threadId, message.role, message.text, message.timestamp, JSON.stringify(message.attachments || [])]); } } catch (error) { console.error("Failed to save messages:", error); @@ -132,11 +133,17 @@ export function createChatStorage(tableName: string): ChatStorage { async getMessages(threadId: string) { try { const result = await alasql.promise(` - SELECT id, role, text, timestamp FROM ${messagesTable} + SELECT id, role, text, timestamp, attachments FROM ${messagesTable} WHERE threadId = ? ORDER BY timestamp ASC - `, [threadId]) as ChatMessage[]; + `, [threadId]) as any[]; - return Array.isArray(result) ? result : []; + return result.map(row => ({ + id: row.id, + role: row.role, + text: row.text, + timestamp: row.timestamp, + attachments: JSON.parse(row.attachments || '[]') + })) as ChatMessage[]; } catch (error) { console.error("Failed to get messages:", error); return []; From 68d4f84cc1914f4d93dfb0dbbaf908169c239f37 Mon Sep 17 00:00:00 2001 From: FARAN Date: Thu, 24 Jul 2025 19:37:10 +0500 Subject: [PATCH 2/5] fix current message update text --- .../src/comps/comps/chatComp/components/ChatCoreMain.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx index ca6a9fa54..2881ff661 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx @@ -146,13 +146,14 @@ export function ChatCoreMain({ attachments: completeAttachments, }; - onMessageUpdate?.(text); await actions.addMessage(state.currentThreadId, userMessage); setIsRunning(true); try { const response = await messageHandler.sendMessage(userMessage); // Send full message object with attachments + onMessageUpdate?.(userMessage.text); + const assistantMessage: ChatMessage = { id: generateId(), role: "assistant", @@ -214,9 +215,6 @@ export function ChatCoreMain({ newMessages.push(editedMessage); - // Expose message update to parent - onMessageUpdate?.(editedMessage.text); - // Update state with edited context await actions.updateMessages(state.currentThreadId, newMessages); setIsRunning(true); @@ -224,6 +222,8 @@ export function ChatCoreMain({ try { const response = await messageHandler.sendMessage(editedMessage); // Send full message object with attachments + onMessageUpdate?.(editedMessage.text); + const assistantMessage: ChatMessage = { id: generateId(), role: "assistant", From a01bb75d57f590cfdb3aa1a68acd5a3e5b84f794 Mon Sep 17 00:00:00 2001 From: FARAN Date: Thu, 24 Jul 2025 19:46:23 +0500 Subject: [PATCH 3/5] optimize history --- .../comps/comps/chatComp/components/ChatCoreMain.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx index 2881ff661..d5b0ce187 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx @@ -87,10 +87,14 @@ export function ChatCoreMain({ // Get messages for current thread const currentMessages = actions.getCurrentMessages(); - // Notify parent component of conversation changes + // Notify parent component of conversation changes - OPTIMIZED TIMING useEffect(() => { - onConversationUpdate?.(currentMessages); - }, [currentMessages]); + // Only update conversationHistory when we have complete conversations + // Skip empty states and intermediate processing states + if (currentMessages.length > 0 && !isRunning) { + onConversationUpdate?.(currentMessages); + } + }, [currentMessages, isRunning]); // Trigger component load event on mount useEffect(() => { From a2160b99fb68178220a432cccd52b8d96fb60804 Mon Sep 17 00:00:00 2001 From: FARAN Date: Thu, 24 Jul 2025 23:59:14 +0500 Subject: [PATCH 4/5] improve attachment logic --- .../chatComp/components/ui/attachment.tsx | 99 ++++++++++--------- 1 file changed, 51 insertions(+), 48 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx index 3da338682..4ad730272 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx @@ -144,73 +144,76 @@ const ScreenReaderOnly = styled.span` overflow: hidden; `; -// ============================================================================ -// UTILITY HOOKS -// ============================================================================ - - -const useFileSrc = (file: File | undefined) => { - const [src, setSrc] = useState(); - const lastFileRef = useRef(); - - useEffect(() => { - if (!file || file === lastFileRef.current) return; - - const objectUrl = URL.createObjectURL(file); - setSrc(objectUrl); - lastFileRef.current = file; - - return () => { - URL.revokeObjectURL(objectUrl); - }; - }, [file]); - - return src; -}; - - const useAttachmentSrc = () => { + // Listen only to image-type attachments const attachment = useAttachment( - useCallback((a: any) => { - if (a.type !== "image") return undefined; - return a; - }, []) + useCallback((a: any) => (a.type === "image" ? a : undefined), []) ); const [src, setSrc] = useState(); - const lastAttachmentRef = useRef(); - useEffect(() => { - if (!attachment || attachment === lastAttachmentRef.current) return; + // Keep track of the last generated object URL so that we can revoke it + const objectUrlRef = useRef(); + const lastAttachmentIdRef = useRef(); - // Handle new/pending attachments with File objects - if (attachment.file && attachment.file instanceof File) { - const objectUrl = URL.createObjectURL(attachment.file); - setSrc(objectUrl); - lastAttachmentRef.current = attachment; + useEffect(() => { + // If the same attachment is rendered again, do nothing + if (!attachment || attachment.id === lastAttachmentIdRef.current) return; + + // Clean up any previous object URL + if (objectUrlRef.current) { + try { + URL.revokeObjectURL(objectUrlRef.current); + } catch { + /* ignore */ + } + objectUrlRef.current = undefined; + } - return () => { - URL.revokeObjectURL(objectUrl); - }; + // ------------------------------------------------------------------ + // 1. New (local) File object – generate a temporary ObjectURL + // ------------------------------------------------------------------ + if (attachment.file instanceof File) { + const url = URL.createObjectURL(attachment.file); + objectUrlRef.current = url; + setSrc(url); + lastAttachmentIdRef.current = attachment.id; + return; } - // Handle saved attachments with base64 data - const imageContent = attachment.content?.find((c: any) => c.type === "image"); - if (imageContent?.image) { - setSrc(imageContent.image); - lastAttachmentRef.current = attachment; + // ------------------------------------------------------------------ + // 2. Restored attachment coming from storage – use stored base64 image + // ------------------------------------------------------------------ + const imgPart = attachment.content?.find((p: any) => p.type === "image"); + if (imgPart?.image) { + setSrc(imgPart.image as string); + lastAttachmentIdRef.current = attachment.id; return; } - // If no valid source found, clear the src + // ------------------------------------------------------------------ + // 3. No usable preview – clear src + // ------------------------------------------------------------------ setSrc(undefined); - lastAttachmentRef.current = attachment; + lastAttachmentIdRef.current = attachment.id; }, [attachment]); + /* Cleanup when the component using this hook unmounts */ + useEffect(() => { + return () => { + if (objectUrlRef.current) { + try { + URL.revokeObjectURL(objectUrlRef.current); + } catch { + /* ignore */ + } + } + }; + }, []); + return src; }; - // ============================================================================ // ATTACHMENT COMPONENTS // ============================================================================ From c845adc4d51c56fbaf5c956d377b3d1fb301d752 Mon Sep 17 00:00:00 2001 From: FARAN Date: Fri, 25 Jul 2025 21:19:46 +0500 Subject: [PATCH 5/5] replace shadcn dialog with antD modal --- .../chatComp/components/ui/attachment.tsx | 64 +++-- .../comps/chatComp/components/ui/dialog.tsx | 230 ------------------ 2 files changed, 29 insertions(+), 265 deletions(-) delete mode 100644 client/packages/lowcoder/src/comps/comps/chatComp/components/ui/dialog.tsx diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx index 4ad730272..ea823edca 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx @@ -9,19 +9,12 @@ import { useAttachment, } from "@assistant-ui/react"; import styled from "styled-components"; +import { Modal } from "antd"; import { Tooltip, TooltipContent, TooltipTrigger, } from "./tooltip"; -import { - Dialog, - DialogTitle, - DialogTrigger, - DialogOverlay, - DialogPortal, - DialogContent, -} from "./dialog"; import { Avatar, AvatarImage, AvatarFallback } from "./avatar"; import { TooltipIconButton } from "../assistant-ui/tooltip-icon-button"; @@ -29,7 +22,7 @@ import { TooltipIconButton } from "../assistant-ui/tooltip-icon-button"; // STYLED COMPONENTS // ============================================================================ -const StyledDialogTrigger = styled(DialogTrigger)` +const StyledModalTrigger = styled.div` cursor: pointer; transition: background-color 0.2s; padding: 2px; @@ -136,13 +129,7 @@ const StyledComposerButton = styled(TooltipIconButton)` transition: opacity 0.2s ease-in; `; -const ScreenReaderOnly = styled.span` - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; -`; +// ScreenReaderOnly component removed as it's no longer needed with ANTD Modal const useAttachmentSrc = () => { @@ -244,21 +231,37 @@ const AttachmentPreview: FC = ({ src }) => { const AttachmentPreviewDialog: FC = ({ children }) => { const src = useAttachmentSrc(); + const [isModalOpen, setIsModalOpen] = useState(false); if (!src) return <>{children}; return ( - - + <> + setIsModalOpen(true)}> {children} - - - - Image Attachment Preview - + + setIsModalOpen(false)} + footer={null} + width="auto" + style={{ + maxWidth: "80vw", + top: 20, + }} + styles={{ + body: { + display: "flex", + justifyContent: "center", + alignItems: "center", + padding: "20px", + } + }} + > - - + + ); }; @@ -363,13 +366,4 @@ export const ComposerAddAttachment: FC = () => { ); -}; - -const AttachmentDialogContent: FC = ({ children }) => ( - - - - {children} - - -); \ No newline at end of file +}; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/dialog.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/dialog.tsx deleted file mode 100644 index 058caebae..000000000 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/dialog.tsx +++ /dev/null @@ -1,230 +0,0 @@ -"use client" - -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" -import styled from "styled-components" - -const StyledDialogOverlay = styled(DialogPrimitive.Overlay)` - position: fixed; - inset: 0; - z-index: 50; - background-color: rgba(0, 0, 0, 0.5); -`; - -const StyledDialogContent = styled(DialogPrimitive.Content)` - background-color: white; - position: fixed; - top: 50%; - left: 50%; - z-index: 50; - display: grid; - width: 100%; - max-width: calc(100% - 2rem); - transform: translate(-50%, -50%); - gap: 16px; - border-radius: 8px; - border: 1px solid #e2e8f0; - padding: 24px; - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); - - @media (min-width: 640px) { - max-width: 512px; - } -`; - -const StyledDialogClose = styled(DialogPrimitive.Close)` - position: absolute; - top: 16px; - right: 16px; - border-radius: 4px; - opacity: 0.7; - transition: opacity 0.2s; - border: none; - background: none; - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - opacity: 1; - } - - & svg { - width: 16px; - height: 16px; - } -`; - -const StyledDialogHeader = styled.div` - display: flex; - flex-direction: column; - gap: 8px; - text-align: center; - - @media (min-width: 640px) { - text-align: left; - } -`; - -const StyledDialogFooter = styled.div` - display: flex; - flex-direction: column-reverse; - gap: 8px; - - @media (min-width: 640px) { - flex-direction: row; - justify-content: flex-end; - } -`; - -const StyledDialogTitle = styled(DialogPrimitive.Title)` - font-size: 18px; - line-height: 1; - font-weight: 600; -`; - -const StyledDialogDescription = styled(DialogPrimitive.Description)` - color: #64748b; - font-size: 14px; -`; - -const ScreenReaderOnly = styled.span` - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; -`; - -function Dialog({ - ...props -}: React.ComponentProps) { - return -} - -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return -} - -function DialogPortal({ - ...props -}: React.ComponentProps) { - return -} - -function DialogClose({ - ...props -}: React.ComponentProps) { - return -} - -function DialogOverlay({ - className, - ...props -}: Omit, 'ref'>) { - return ( - - ) -} - -function DialogContent({ - className, - children, - showCloseButton = true, - ...props -}: Omit, 'ref'> & { - showCloseButton?: boolean -}) { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ) -} - -function DialogHeader({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - - ) -} - -function DialogFooter({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - - ) -} - -function DialogTitle({ - className, - ...props -}: Omit, 'ref'>) { - return ( - - ) -} - -function DialogDescription({ - className, - ...props -}: Omit, 'ref'>) { - return ( - - ) -} - -export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, -} \ No newline at end of file