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..d5b0ce187 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,315 @@
-// 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 - OPTIMIZED TIMING
+ useEffect(() => {
+ // Only update conversationHistory when we have complete conversations
+ // Skip empty states and intermediate processing states
+ if (currentMessages.length > 0 && !isRunning) {
+ onConversationUpdate?.(currentMessages);
+ }
+ }, [currentMessages, isRunning]);
+
+ // 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,
+ };
+
+ 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",
+ 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);
+
+ // Update state with edited context
+ await actions.updateMessages(state.currentThreadId, newMessages);
+ setIsRunning(true);
+
+ try {
+ const response = await messageHandler.sendMessage(editedMessage); // Send full message object with attachments
+
+ onMessageUpdate?.(editedMessage.text);
+
+ const 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..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
@@ -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,
@@ -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,55 +129,78 @@ 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
-// ============================================================================
-// UTILITY HOOKS
-// ============================================================================
-// Simple replacement for useShallow (removes zustand dependency)
-const useShallow =
(selector: (state: any) => T): ((state: any) => T) => selector;
+const useAttachmentSrc = () => {
+ // Listen only to image-type attachments
+ const attachment = useAttachment(
+ useCallback((a: any) => (a.type === "image" ? a : undefined), [])
+ );
+
+ const [src, setSrc] = useState();
-const useFileSrc = (file: File | undefined) => {
- const [src, setSrc] = useState(undefined);
+ // Keep track of the last generated object URL so that we can revoke it
+ const objectUrlRef = useRef();
+ const lastAttachmentIdRef = useRef();
useEffect(() => {
- if (!file) {
- setSrc(undefined);
+ // 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;
+ }
+
+ // ------------------------------------------------------------------
+ // 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;
+ }
+
+ // ------------------------------------------------------------------
+ // 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;
}
- const objectUrl = URL.createObjectURL(file);
- setSrc(objectUrl);
+ // ------------------------------------------------------------------
+ // 3. No usable preview – clear src
+ // ------------------------------------------------------------------
+ setSrc(undefined);
+ lastAttachmentIdRef.current = attachment.id;
+ }, [attachment]);
+ /* Cleanup when the component using this hook unmounts */
+ useEffect(() => {
return () => {
- URL.revokeObjectURL(objectUrl);
+ if (objectUrlRef.current) {
+ try {
+ URL.revokeObjectURL(objectUrlRef.current);
+ } catch {
+ /* ignore */
+ }
+ }
};
- }, [file]);
+ }, []);
return src;
};
-
-const useAttachmentSrc = () => {
- const { file, src } = useAttachment(
- useShallow((a): { file?: File; src?: string } => {
- if (a.type !== "image") return {};
- if (a.file) return { file: a.file };
- const src = a.content?.filter((c: any) => c.type === "image")[0]?.image;
- if (!src) return {};
- return { src };
- })
- );
-
- return useFileSrc(file) ?? src;
-};
-
// ============================================================================
// ATTACHMENT COMPONENTS
// ============================================================================
@@ -215,21 +231,37 @@ const AttachmentPreview: FC = ({ src }) => {
const AttachmentPreviewDialog: FC = ({ children }) => {
const src = useAttachmentSrc();
+ const [isModalOpen, setIsModalOpen] = useState(false);
if (!src) return <>{children}>;
return (
-
+
+ >
);
};
@@ -334,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
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 [];