diff --git a/frontend/app/[locale]/chat/internal/chatAttachment.tsx b/frontend/app/[locale]/chat/components/chatAttachment.tsx similarity index 99% rename from frontend/app/[locale]/chat/internal/chatAttachment.tsx rename to frontend/app/[locale]/chat/components/chatAttachment.tsx index 4521f633f..53483b238 100644 --- a/frontend/app/[locale]/chat/internal/chatAttachment.tsx +++ b/frontend/app/[locale]/chat/components/chatAttachment.tsx @@ -453,4 +453,4 @@ export function ChatAttachment({ )} ); -} +} \ No newline at end of file diff --git a/frontend/app/[locale]/chat/components/chatLeftSidebar.tsx b/frontend/app/[locale]/chat/components/chatLeftSidebar.tsx index 8ade0d72a..6ef06e023 100644 --- a/frontend/app/[locale]/chat/components/chatLeftSidebar.tsx +++ b/frontend/app/[locale]/chat/components/chatLeftSidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Clock, Plus, @@ -102,13 +102,18 @@ export function ChatSidebar({ }: ChatSidebarProps) { const { t } = useTranslation(); const { confirm } = useConfirmModal(); - const { today, week, older } = categorizeConversations(conversationManagement.conversationList); const [editingId, setEditingId] = useState(null); const [renameValue, setRenameValue] = useState(""); const [renameError, setRenameError] = useState(null); const [collapsed, setCollapsed] = useState(false); const [openDropdownId, setOpenDropdownId] = useState(null); + // Memoize conversation categorization to avoid redundant work on unrelated state changes + const { today, week, older } = useMemo( + () => categorizeConversations(conversationManagement.conversationList), + [conversationManagement.conversationList] + ); + const onToggleSidebar = () => setCollapsed((prev) => !prev); const handleRenameClick = (conversationId: number, currentTitle: string) => { diff --git a/frontend/app/[locale]/chat/components/chatRightPanel.tsx b/frontend/app/[locale]/chat/components/chatRightPanel.tsx index 9eb9f6a7d..18e534f3e 100644 --- a/frontend/app/[locale]/chat/components/chatRightPanel.tsx +++ b/frontend/app/[locale]/chat/components/chatRightPanel.tsx @@ -8,6 +8,231 @@ import { convertImageUrlToApiUrl, extractObjectNameFromUrl, storageService } fro import { message, Button } from "antd"; import log from "@/lib/logger"; import { useConfig } from "@/hooks/useConfig"; +import type { AppConfig } from "@/types/modelConfig"; + +interface SearchResultItemProps { + result: SearchResult; + t: any; // TFunction from react-i18next + appConfig: AppConfig | null; +} + +// Search result item component - moved to module scope to prevent re-creation on each render +function SearchResultItem({ result, t, appConfig }: SearchResultItemProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const title = result.title || t("chatRightPanel.unknownTitle"); + const url = result.url || "#"; + const text = result.text || t("chatRightPanel.noContentDescription"); + const published_date = result.published_date || ""; + const source_type = result.source_type || "url"; + const filename = result.filename || result.title || ""; + const datamateDatasetId = result.score_details?.datamate_dataset_id; + const datamateFileId = result.score_details?.datamate_file_id; + const datamateBaseUrl = result.score_details?.datamate_base_url; + + // Handle file download + const handleFileDownload = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!filename && !url) { + message.error(t("chatRightPanel.fileDownloadError", "File name or URL is missing")); + return; + } + + setIsDownloading(true); + try { + if (source_type === "datamate") { + if (!appConfig?.modelEngineEnabled) { + message.error("DataMate download not available: ModelEngine is not enabled"); + return; + } + if (!datamateDatasetId || !datamateFileId || !datamateBaseUrl) { + if (!url || url === "#") { + message.error(t("chatRightPanel.fileDownloadError", "Missing Datamate dataset or file information")); + return; + } + } + await storageService.downloadDatamateFile({ + url: url !== "#" ? url : undefined, + baseUrl: datamateBaseUrl, + datasetId: datamateDatasetId, + fileId: datamateFileId, + filename: filename || undefined, + }); + message.success(t("chatRightPanel.fileDownloadSuccess", "File download started")); + return; + } + + let objectName: string | undefined = undefined; + + if (url && url !== "#") { + objectName = extractObjectNameFromUrl(url) || undefined; + } + + if (!objectName) { + message.error(t("chatRightPanel.fileDownloadError", "Cannot determine file object name")); + return; + } + + await storageService.downloadFile(objectName, filename || "download"); + message.success(t("chatRightPanel.fileDownloadSuccess", "File download started")); + } catch (error) { + log.error("Failed to download file:", error); + message.error(t("chatRightPanel.fileDownloadError", "Failed to download file. Please try again.")); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+
+
+ {source_type === "url" ? ( + + {title} + + ) : source_type === "file" || source_type === "datamate" ? ( + + {isDownloading ? ( + + + {t("chatRightPanel.downloading", "Downloading...")} + + ) : ( + title + )} + + ) : ( +
+ {title} +
+ )} + + {published_date && ( +
+ {formatDate(published_date)} +
+ )} +
+ +
+

+ {text} +

+
+ +
+
+ {source_type === "file" || source_type === "datamate" ? ( + <> + +
+
+ +
+
+ {source_type === "datamate" + ? t("chatRightPanel.source.datamate", "Source: Datamate") + : source_type === "file" + ? t("chatRightPanel.source.nexent", "Source: Nexent") + : ""} +
+
+ + ) : ( +
+
+ +
+ + {formatUrl(result)} + +
+ )} +
+ + {text.length > 150 && ( + + )} +
+
+
+ ); +} export function ChatRightPanel({ @@ -215,228 +440,6 @@ export function ChatRightPanel({ setViewingImage(imageUrl); }; - // Search result item component - const SearchResultItem = ({ result }: { result: SearchResult }) => { - const [isExpanded, setIsExpanded] = useState(false); - const [isDownloading, setIsDownloading] = useState(false); - const title = result.title || t("chatRightPanel.unknownTitle"); - const url = result.url || "#"; - const text = result.text || t("chatRightPanel.noContentDescription"); - const published_date = result.published_date || ""; - const source_type = result.source_type || "url"; - const filename = result.filename || result.title || ""; - const datamateDatasetId = result.score_details?.datamate_dataset_id; - const datamateFileId = result.score_details?.datamate_file_id; - const datamateBaseUrl = result.score_details?.datamate_base_url; - - // Handle file download - const handleFileDownload = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - if (!filename && !url) { - message.error(t("chatRightPanel.fileDownloadError", "File name or URL is missing")); - return; - } - - setIsDownloading(true); - try { - // Handle datamate source type - if (source_type === "datamate") { - if (!appConfig?.modelEngineEnabled) { - message.error("DataMate download not available: ModelEngine is not enabled"); - return; - } - if (!datamateDatasetId || !datamateFileId || !datamateBaseUrl) { - if (!url || url === "#") { - message.error(t("chatRightPanel.fileDownloadError", "Missing Datamate dataset or file information")); - return; - } - } - await storageService.downloadDatamateFile({ - url: url !== "#" ? url : undefined, - baseUrl: datamateBaseUrl, - datasetId: datamateDatasetId, - fileId: datamateFileId, - filename: filename || undefined, - }); - message.success(t("chatRightPanel.fileDownloadSuccess", "File download started")); - return; - } - - // Handle regular file source type (source_type === "file") - // For knowledge base files, backend stores the MinIO object_name in path_or_url, - // so we should always try to extract it from the URL and avoid guessing from filename. - let objectName: string | undefined = undefined; - - if (url && url !== "#") { - objectName = extractObjectNameFromUrl(url) || undefined; - } - - if (!objectName) { - message.error(t("chatRightPanel.fileDownloadError", "Cannot determine file object name")); - return; - } - - await storageService.downloadFile(objectName, filename || "download"); - message.success(t("chatRightPanel.fileDownloadSuccess", "File download started")); - } catch (error) { - log.error("Failed to download file:", error); - message.error(t("chatRightPanel.fileDownloadError", "Failed to download file. Please try again.")); - } finally { - setIsDownloading(false); - } - }; - - return ( -
-
-
- {source_type === "url" ? ( - - {title} - - ) : source_type === "file" || source_type === "datamate" ? ( - - {isDownloading ? ( - - - {t("chatRightPanel.downloading", "Downloading...")} - - ) : ( - title - )} - - ) : ( -
- {title} -
- )} - - {published_date && ( -
- {formatDate(published_date)} -
- )} -
- -
-

- {text} -

-
- -
-
- {source_type === "file" || source_type === "datamate" ? ( - <> - -
-
- -
-
- {source_type === "datamate" - ? t("chatRightPanel.source.datamate", "Source: Datamate") - : source_type === "file" - ? t("chatRightPanel.source.nexent", "Source: Nexent") - : ""} -
-
- - ) : ( -
-
- -
- - {formatUrl(result)} - -
- )} -
- - {text.length > 150 && ( - - )} -
-
-
- ); - }; - // Render image component const renderImage = (imageUrl: string, index: number) => { const item = imageData[imageUrl]; @@ -589,6 +592,8 @@ export function ChatRightPanel({ ))} diff --git a/frontend/app/[locale]/chat/internal/ChatTopNavContent.tsx b/frontend/app/[locale]/chat/internal/ChatTopNavContent.tsx deleted file mode 100644 index c93e60555..000000000 --- a/frontend/app/[locale]/chat/internal/ChatTopNavContent.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { useConfig } from "@/hooks/useConfig"; -import { extractColorsFromUri } from "@/lib/avatar"; -import { useRouter } from "next/navigation"; -import { useTranslation } from "react-i18next"; - -/** - * ChatTopNavContent - Displays app logo and name in the top navbar for chat page - */ -export function ChatTopNavContent() { - const router = useRouter(); - const { i18n } = useTranslation(); - const { appConfig, getAppAvatarUrl } = useConfig(); - const sidebarAvatarUrl = getAppAvatarUrl(16); - - // Static font-size for top navbar (no responsive sizing required) - - const colors = extractColorsFromUri(appConfig.avatarUri || ""); - const mainColor = colors.mainColor || "273746"; - const secondaryColor = colors.secondaryColor || mainColor; - - return ( -
router.push(`/${i18n.language}`)} - > -
- {appConfig.appName} -
- - {appConfig.appName} - -
- ); -} - diff --git a/frontend/app/[locale]/chat/internal/chatHelpers.tsx b/frontend/app/[locale]/chat/internal/chatHelpers.tsx deleted file mode 100644 index 9e8272608..000000000 --- a/frontend/app/[locale]/chat/internal/chatHelpers.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// Handle duplicate search results -export const deduplicateSearchResults = ( - existingResults: any[], - newResults: any[] -): any[] => { - const uniqueResults = [...existingResults]; - const existingTexts = new Set(existingResults.map((item) => item.text)); - - for (const result of newResults) { - if (!existingTexts.has(result.text)) { - uniqueResults.push(result); - existingTexts.add(result.text); - } - } - - return uniqueResults; -}; - -// Handle duplicate images -export const deduplicateImages = ( - existingImages: string[], - newImages: string[] -): string[] => { - const uniqueImages = [...existingImages]; - const existingUrls = new Set(existingImages); - - for (const imageUrl of newImages) { - if (!existingUrls.has(imageUrl)) { - uniqueImages.push(imageUrl); - existingUrls.add(imageUrl); - } - } - - return uniqueImages; -}; diff --git a/frontend/app/[locale]/chat/internal/chatInterface.tsx b/frontend/app/[locale]/chat/internal/chatInterface.tsx index 785ff3c1c..eea785e18 100644 --- a/frontend/app/[locale]/chat/internal/chatInterface.tsx +++ b/frontend/app/[locale]/chat/internal/chatInterface.tsx @@ -29,14 +29,14 @@ import { uploadAttachments, createMessageAttachments, cleanupAttachmentUrls, -} from "@/app/chat/internal/chatPreprocess"; +} from "@/lib/chat/chatAttachmentUtils"; import { ConversationListItem, ApiConversationDetail } from "@/types/chat"; import { ChatMessageType } from "@/types/chat"; import { handleStreamResponse } from "@/app/chat/streaming/chatStreamHandler"; import { extractUserMsgFromResponse, extractAssistantMsgFromResponse, -} from "./extractMsgFromHistoryResponse"; +} from "@/lib/chatMessageExtractor"; import { Layout } from "antd"; import log from "@/lib/logger"; diff --git a/frontend/app/[locale]/chat/internal/chatPreprocess.tsx b/frontend/app/[locale]/chat/internal/chatPreprocess.tsx deleted file mode 100644 index 7535186fb..000000000 --- a/frontend/app/[locale]/chat/internal/chatPreprocess.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import { conversationService } from "@/services/conversationService"; -import { storageService } from "@/services/storageService"; -import { FilePreview, AgentStep } from "@/types/chat"; -import log from "@/lib/logger"; - -// Step ID Counter -const stepIdCounter = { current: 0 }; - -/** - * Parse agent steps, convert text content to structured steps - */ -export const parseAgentSteps = ( - content: string, - defaultExpanded: boolean = false, - t: any -): AgentStep[] => { - const steps: AgentStep[] = []; - const stepRegex = /]*>([\s\S]*?)<\/step>/g; - let match; - - while ((match = stepRegex.exec(content)) !== null) { - const stepContent = match[1]; - const titleMatch = /([\s\S]*?)<\/title>/i.exec(stepContent); - const contentMatch = /<content>([\s\S]*?)<\/content>/i.exec(stepContent); - - const step: AgentStep = { - id: `step-${stepIdCounter.current++}`, - title: titleMatch ? titleMatch[1].trim() : t("chatPreprocess.step"), - content: "", - expanded: defaultExpanded, - thinking: { content: "", expanded: false }, - code: { content: "", expanded: false }, - output: { content: "", expanded: false }, - metrics: "", - contents: [], - }; - - if (contentMatch) { - step.contents = [ - { - id: `content-${Date.now()}-${Math.random() - .toString(36) - .substring(2, 7)}`, - type: "model_output", - content: contentMatch[1], - expanded: false, - timestamp: Date.now(), - }, - ]; - } - - steps.push(step); - } - - return steps; -}; - -/** - * Handle attachment file preprocessing - * @param content User message content - * @param attachments Attachment list - * @param signal AbortController signal - * @param onProgress Preprocessing progress callback - * @param t Translation function - * @param conversationId Conversation ID - * @returns Preprocessed query and processing status - */ -export const preprocessAttachments = async ( - content: string, - attachments: FilePreview[], - signal: AbortSignal, - onProgress: (data: any) => void, - t: any, - conversationId?: number -): Promise<{ - finalQuery: string; - success: boolean; - error?: string; - fileDescriptions?: Record<string, string>; -}> => { - if (attachments.length === 0) { - return { finalQuery: content, success: true }; - } - - // Skip preprocessing API call - return original content directly - // If you want to re-enable preprocessing, uncomment the code below - return { finalQuery: content, success: true }; - - /* - // Original preprocessing code (disabled) - try { - // Call file preprocessing interface - const preProcessReader = await conversationService.preprocessFiles( - content, - attachments.map((attachment) => attachment.file), - conversationId, - signal - ); - - if (!preProcessReader) - throw new Error(t("chatPreprocess.preprocessResponseEmpty")); - - const preProcessDecoder = new TextDecoder(); - let preProcessBuffer = ""; - let finalQuery = content; - const fileDescriptions: Record<string, string> = {}; - - while (true) { - const { done, value } = await preProcessReader.read(); - if (done) { - break; - } - - preProcessBuffer += preProcessDecoder.decode(value, { stream: true }); - - const lines = preProcessBuffer.split("\n"); - preProcessBuffer = lines.pop() || ""; - - for (const line of lines) { - if (line.startsWith("data:")) { - const jsonStr = line.substring(5).trim(); - try { - const jsonData = JSON.parse(jsonStr); - - // Callback progress information - onProgress(jsonData); - - // If it is file processing information, save file description - if ( - jsonData.type === "file_processed" && - jsonData.filename && - jsonData.description - ) { - fileDescriptions[jsonData.filename] = jsonData.description; - } - - // If it is a completion message, record the final query - if (jsonData.type === "complete") { - finalQuery = jsonData.final_query; - } - } catch (e) { - log.error( - t("chatPreprocess.parsingPreprocessDataFailed"), - e, - jsonStr - ); - } - } - } - } - - return { finalQuery, success: true, fileDescriptions }; - } catch (error) { - log.error(t("chatPreprocess.filePreprocessingFailed"), error); - return { - finalQuery: content, - success: false, - error: error instanceof Error ? (error as Error).message : String(error), - }; - } - */ -}; - -/** - * Create thinking step - * @param message Message to display - * @returns Thinking step object - */ -export const createThinkingStep = (t: any, message?: string): AgentStep => { - const displayMessage = message || t("chatPreprocess.parsingFile"); - return { - id: `thinking-${Date.now()}`, - title: t("chatPreprocess.thinking"), - content: displayMessage, - expanded: true, - thinking: { content: displayMessage, expanded: true }, - code: { content: "", expanded: false }, - output: { content: "", expanded: false }, - metrics: "", - contents: [], - }; -}; - -/** - * Handle file upload - * @param file Uploaded file - * @param setFileUrls Callback function to set file URL - * @returns File ID - */ -export const handleFileUpload = ( - file: File, - setFileUrls: React.Dispatch<React.SetStateAction<Record<string, string>>>, - t: any -): string => { - const fileId = `file-${Date.now()}-${Math.random() - .toString(36) - .substring(7)}`; - - // If it is not an image type, create a file preview URL - if (!file.type.startsWith("image/")) { - const fileUrl = URL.createObjectURL(file); - setFileUrls((prev) => ({ ...prev, [fileId]: fileUrl })); - } - - return fileId; -}; - -/** - * Handle image upload - * @param file Uploaded image file - */ -export const handleImageUpload = (file: File, t: any): void => {}; - -/** - * Upload attachments to storage service - * @param attachments Attachment list - * @returns Uploaded file URLs and object names - */ -export const uploadAttachments = async ( - attachments: FilePreview[], - t: any -): Promise<{ - uploadedFileUrls: Record<string, string>; - objectNames: Record<string, string>; - error?: string; -}> => { - if (attachments.length === 0) { - return { uploadedFileUrls: {}, objectNames: {} }; - } - - try { - // Upload all files to storage service - const uploadResult = await storageService.uploadFiles( - attachments.map((attachment) => attachment.file) - ); - - // Handle upload results - const uploadedFileUrls: Record<string, string> = {}; - const objectNames: Record<string, string> = {}; - - if (uploadResult.success_count > 0) { - uploadResult.results.forEach((result) => { - if (result.success) { - uploadedFileUrls[result.file_name] = result.url; - objectNames[result.file_name] = result.object_name; - } - }); - } - - return { uploadedFileUrls, objectNames }; - } catch (error) { - log.error(t("chatPreprocess.fileUploadFailed"), error); - return { - uploadedFileUrls: {}, - objectNames: {}, - error: error instanceof Error ? error.message : String(error), - }; - } -}; - -/** - * Create message attachment objects from attachment list - * @param attachments Attachment list - * @param uploadedFileUrls Uploaded file URLs - * @param fileUrls File URL mapping - * @returns Message attachment object array - */ -export const createMessageAttachments = ( - attachments: FilePreview[], - uploadedFileUrls: Record<string, string>, - fileUrls: Record<string, string> -): { type: string; name: string; size: number; url?: string }[] => { - return attachments.map((attachment) => ({ - type: attachment.type, - name: attachment.file.name, - size: attachment.file.size, - url: - uploadedFileUrls[attachment.file.name] || - (attachment.type === "image" - ? attachment.previewUrl - : fileUrls[attachment.id]), - })); -}; - -/** - * Clean up attachment URLs - * @param attachments Attachment list - * @param fileUrls File URL mapping - */ -export const cleanupAttachmentUrls = ( - attachments: FilePreview[], - fileUrls: Record<string, string> -): void => { - // Clean up attachment preview URLs - attachments.forEach((attachment) => { - if (attachment.previewUrl) { - URL.revokeObjectURL(attachment.previewUrl); - } - }); - - // Clean up other file URLs - Object.values(fileUrls).forEach((url) => { - URL.revokeObjectURL(url); - }); -}; diff --git a/frontend/app/[locale]/chat/page.tsx b/frontend/app/[locale]/chat/page.tsx index 1b1287f6f..c9c165ff9 100644 --- a/frontend/app/[locale]/chat/page.tsx +++ b/frontend/app/[locale]/chat/page.tsx @@ -5,6 +5,7 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import { useDeployment } from "@/components/providers/deploymentProvider"; import { useConfig } from "@/hooks/useConfig"; import { ChatInterface } from "./internal/chatInterface"; +import "@/styles/chat.css"; /** * ChatContent component - Main chat page content diff --git a/frontend/app/[locale]/chat/streaming/chatStreamFinalMessage.tsx b/frontend/app/[locale]/chat/streaming/chatStreamFinalMessage.tsx index e047a775a..3ecb1d9f8 100644 --- a/frontend/app/[locale]/chat/streaming/chatStreamFinalMessage.tsx +++ b/frontend/app/[locale]/chat/streaming/chatStreamFinalMessage.tsx @@ -20,7 +20,7 @@ import { copyToClipboard } from "@/lib/clipboard"; import log from "@/lib/logger"; import { AttachmentItem } from "@/types/chat"; import { MESSAGE_ROLES } from "@/const/chatConfig"; -import { ChatAttachment } from "../internal/chatAttachment"; +import { ChatAttachment } from "../components/chatAttachment"; interface FinalMessageProps { message: ChatMessageType; diff --git a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx index 484d5cb4a..bc8452cbb 100644 --- a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx +++ b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx @@ -5,10 +5,37 @@ import { ChatMessageType, AgentStep } from "@/types/chat"; import log from "@/lib/logger"; import { MESSAGE_ROLES } from "@/const/chatConfig"; -import { - deduplicateImages, - deduplicateSearchResults, -} from "../internal/chatHelpers"; +// Merge new search results into an existing list, skipping duplicates by `text` field +const deduplicateSearchResults = ( + existingResults: any[], + newResults: any[] +): any[] => { + const uniqueResults = [...existingResults]; + const existingTexts = new Set(existingResults.map((item) => item.text)); + for (const result of newResults) { + if (!existingTexts.has(result.text)) { + uniqueResults.push(result); + existingTexts.add(result.text); + } + } + return uniqueResults; +}; + +// Merge new image URLs into an existing list, skipping duplicates +const deduplicateImages = ( + existingImages: string[], + newImages: string[] +): string[] => { + const uniqueImages = [...existingImages]; + const existingUrls = new Set(existingImages); + for (const imageUrl of newImages) { + if (!existingUrls.has(imageUrl)) { + uniqueImages.push(imageUrl); + existingUrls.add(imageUrl); + } + } + return uniqueImages; +}; // function: process the user break tag const processUserBreakTag = (content: string, t: any): string => { diff --git a/frontend/app/[locale]/chat/streaming/chatStreamMain.tsx b/frontend/app/[locale]/chat/streaming/chatStreamMain.tsx index 912450372..05bd8878d 100644 --- a/frontend/app/[locale]/chat/streaming/chatStreamMain.tsx +++ b/frontend/app/[locale]/chat/streaming/chatStreamMain.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useState } from "react"; +import { useRef, useEffect, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; @@ -62,41 +62,12 @@ export function ChatStreamMain({ const [showScrollButton, setShowScrollButton] = useState(false); const [showTopFade, setShowTopFade] = useState(false); const [autoScroll, setAutoScroll] = useState(true); - const [chatInputHeight, setChatInputHeight] = useState(130); // Default ChatInput height - const [processedMessages, setProcessedMessages] = useState<ProcessedMessages>( - { - finalMessages: [], - taskMessages: [], - conversationGroups: new Map(), - } - ); + const [chatInputHeight, setChatInputHeight] = useState(130); const lastUserMessageIdRef = useRef<string | null>(null); const messagesEndRef = useRef<HTMLDivElement>(null); - // Monitor ChatInput height changes - useEffect(() => { - const chatInputElement = chatInputRef.current; - if (!chatInputElement) return; - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const height = entry.contentRect.height; - setChatInputHeight(height); - } - }); - - resizeObserver.observe(chatInputElement); - - // Set initial height - setChatInputHeight(chatInputElement.getBoundingClientRect().height); - - return () => { - resizeObserver.disconnect(); - }; - }, [processedMessages.finalMessages.length]); // Re-observe when messages change (initial vs regular mode) - - // Handle message classification - useEffect(() => { + // Process messages with useMemo to avoid double-render on each SSE chunk + const processedMessages = useMemo<ProcessedMessages>(() => { const finalMsgs: ChatMessageType[] = []; // Track the latest user message ID for scroll behavior @@ -108,31 +79,49 @@ export function ChatStreamMain({ // Process all messages, distinguish user messages and final answers messages.forEach((message) => { - // User messages are directly added to the final message array if (message.role === MESSAGE_ROLES.USER) { finalMsgs.push(message); - } - // Assistant messages - if there is a final answer or content, add it to the final message array - else if (message.role === MESSAGE_ROLES.ASSISTANT) { + } else if (message.role === MESSAGE_ROLES.ASSISTANT) { if (message.finalAnswer || message.content !== undefined) { finalMsgs.push(message); } } }); - // Use unified message transformer (includeCode: false for normal chat mode) const { taskMessages: taskMsgs, conversationGroups } = transformMessagesToTaskMessages( messages, { includeCode: false } ); - setProcessedMessages({ + return { finalMessages: finalMsgs, taskMessages: taskMsgs, conversationGroups: conversationGroups, - }); + }; }, [messages]); + // Monitor ChatInput height changes + useEffect(() => { + const chatInputElement = chatInputRef.current; + if (!chatInputElement) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const height = entry.contentRect.height; + setChatInputHeight(height); + } + }); + + resizeObserver.observe(chatInputElement); + + // Set initial height + setChatInputHeight(chatInputElement.getBoundingClientRect().height); + + return () => { + resizeObserver.disconnect(); + }; + }, [processedMessages.finalMessages.length]); + // Listen for scroll events useEffect(() => { const scrollAreaElement = scrollAreaRef.current?.querySelector( @@ -210,105 +199,86 @@ export function ChatStreamMain({ }, 0); }; - // Force scroll to bottom when entering history conversation + // Unified auto-scroll effect: handles all scroll triggers in one place useEffect(() => { + const scrollAreaElement = scrollAreaRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement | null; + + if (!scrollAreaElement) return; + + // Force scroll when shouldScrollToBottom is true (e.g., entering history conversation) if (shouldScrollToBottom && processedMessages.finalMessages.length > 0) { setAutoScroll(true); - scrollToBottom(false); - - setTimeout(() => { + requestAnimationFrame(() => { scrollToBottom(false); - }, 300); + // Double-scroll for safety after initial render + setTimeout(() => scrollToBottom(false), 300); + }); + return; } - }, [shouldScrollToBottom, processedMessages.finalMessages.length]); - // Scroll to bottom when messages are updated (if user is already at the bottom) - useEffect(() => { - if (processedMessages.finalMessages.length > 0 && autoScroll) { - const scrollAreaElement = scrollAreaRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ); - if (!scrollAreaElement) return; - - const { scrollTop, scrollHeight, clientHeight } = - scrollAreaElement as HTMLElement; + // Auto-scroll when messages update, if user is near bottom + if (autoScroll && processedMessages.finalMessages.length > 0) { + const { scrollTop, scrollHeight, clientHeight } = scrollAreaElement; const distanceToBottom = scrollHeight - scrollTop - clientHeight; - // When shouldScrollToBottom is true, force scroll to the bottom, regardless of distance. - if (shouldScrollToBottom || distanceToBottom < 50) { - scrollToBottom(); + // Scroll if user is within 150px of bottom + if (distanceToBottom < 150) { + requestAnimationFrame(() => scrollToBottom()); } } }, [ processedMessages.finalMessages.length, processedMessages.conversationGroups.size, + processedMessages.taskMessages.length, + isStreaming, autoScroll, shouldScrollToBottom, ]); - // Additional scroll trigger for async content like Mermaid diagrams + // Observe async content height changes (e.g., diagrams/images) and scroll when near bottom useEffect(() => { - if (processedMessages.finalMessages.length > 0 && autoScroll) { - const scrollAreaElement = scrollAreaRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ); - if (!scrollAreaElement) return; - - // Use ResizeObserver to detect when content height changes (e.g., Mermaid diagrams finish rendering) - const resizeObserver = new ResizeObserver(() => { - const { scrollTop, scrollHeight, clientHeight } = - scrollAreaElement as HTMLElement; - const distanceToBottom = scrollHeight - scrollTop - clientHeight; - - // Auto-scroll if user is near bottom and content height changed - if (distanceToBottom < 100) { - scrollToBottom(); - } - }); + const scrollAreaElement = scrollAreaRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement | null; - resizeObserver.observe(scrollAreaElement); + // Guard for environments without DOM / ResizeObserver + if (!scrollAreaElement || typeof ResizeObserver === "undefined") { + return; + } - // Also use a timeout as fallback for async content - const timeoutId = setTimeout(() => { - const { scrollTop, scrollHeight, clientHeight } = - scrollAreaElement as HTMLElement; - const distanceToBottom = scrollHeight - scrollTop - clientHeight; + let previousScrollHeight = scrollAreaElement.scrollHeight; - if (distanceToBottom < 100) { - scrollToBottom(); - } - }, 1000); // Wait 1 second for async content to render + const observer = new ResizeObserver(() => { + // Only auto-scroll when enabled + if (!autoScroll) { + previousScrollHeight = scrollAreaElement.scrollHeight; + return; + } - return () => { - resizeObserver.disconnect(); - clearTimeout(timeoutId); - }; - } - }, [processedMessages.finalMessages.length, autoScroll]); + const { scrollTop, scrollHeight, clientHeight } = scrollAreaElement; + const heightIncreased = scrollHeight > previousScrollHeight; + previousScrollHeight = scrollHeight; - // Scroll to bottom when task messages are updated - useEffect(() => { - if (autoScroll) { - const scrollAreaElement = scrollAreaRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ); - if (!scrollAreaElement) return; + if (!heightIncreased) { + return; + } - const { scrollTop, scrollHeight, clientHeight } = - scrollAreaElement as HTMLElement; const distanceToBottom = scrollHeight - scrollTop - clientHeight; - // When shouldScrollToBottom is true, force scroll to the bottom, regardless of distance. - if (shouldScrollToBottom || distanceToBottom < 150) { - scrollToBottom(); + // If user is already near the bottom, keep them pinned when content grows + if (distanceToBottom < 200) { + requestAnimationFrame(() => scrollToBottom()); } - } - }, [ - processedMessages.taskMessages.length, - isStreaming, - autoScroll, - shouldScrollToBottom, - ]); + }); + + observer.observe(scrollAreaElement); + + return () => { + observer.disconnect(); + }; + }, [autoScroll]); return ( <div className="flex-1 flex flex-col overflow-hidden relative custom-scrollbar bg-white"> @@ -474,18 +444,6 @@ export function ChatStreamMain({ </AnimatePresence> )} - {/* Add animation keyframes */} - <style jsx global>{` - @keyframes taskWindowEnter { - to { - opacity: 1; - transform: translateY(0); - } - } - .animate-task-window { - animation: taskWindowEnter 0.5s ease-out forwards; - } - `}</style> </div> ); } diff --git a/frontend/app/[locale]/chat/streaming/taskWindow.tsx b/frontend/app/[locale]/chat/streaming/taskWindow.tsx index 6e48e52d4..daafa0e94 100644 --- a/frontend/app/[locale]/chat/streaming/taskWindow.tsx +++ b/frontend/app/[locale]/chat/streaming/taskWindow.tsx @@ -1475,119 +1475,6 @@ function TaskWindowInner({ messages, isStreaming = false, defaultExpanded = true </div> )} </div> - - {/* Add necessary CSS animations */} - <style jsx global>{` - @keyframes blinkingDot { - 0% { - background-color: rgba(59, 130, 246, 0.5); - } - 50% { - background-color: rgba(79, 70, 229, 1); - } - 100% { - background-color: rgba(59, 130, 246, 0.5); - } - } - .blinkingDot { - animation: blinkingDot 1.5s infinite ease-in-out; - background-color: rgba(79, 70, 229, 1); - box-shadow: 0 0 5px rgba(79, 70, 229, 0.5); - } - - /* For the code block style in task-message-content */ - /* Allow code-block-container to use its default styles */ - .task-message-content .code-block-container { - max-width: 100% !important; - margin: 8px 0 !important; - } - - .task-message-content .code-block-content pre { - white-space: pre-wrap !important; - word-wrap: break-word !important; - word-break: break-word !important; - overflow-wrap: break-word !important; - max-width: 100% !important; - box-sizing: border-box !important; - } - - /* For inline code and fallback code */ - .task-message-content code:not(.code-block-content code) { - white-space: pre-wrap !important; - word-wrap: break-word !important; - word-break: break-word !important; - overflow-wrap: break-word !important; - max-width: 100% !important; - } - - /* Ensure the content of the SyntaxHighlighter component wraps correctly */ - .task-message-content .react-syntax-highlighter-line-number { - white-space: nowrap !important; - } - - /* Make sure the entire container is not stretched by the content */ - .task-message-content { - max-width: 100% !important; - word-wrap: break-word !important; - word-break: break-word !important; - } - - /* Allow code block container to overflow if needed for proper display */ - .task-message-content .code-block-container { - overflow: visible !important; - } - - .task-message-content * { - max-width: 100% !important; - box-sizing: border-box !important; - } - - /* Exception for code block container - allow it to use its default overflow */ - .task-message-content .code-block-container * { - max-width: none !important; - } - - /* Override diagram size in task window */ - .task-message-content .my-4 { - max-width: 200px !important; - margin: 0 auto !important; - display: flex !important; - justify-content: center !important; - } - - .task-message-content .my-4 img { - max-width: 200px !important; - width: 200px !important; - margin: 0 auto !important; - display: block !important; - } - - /* More specific selectors for mermaid diagrams */ - .task-message-content .task-message-content .my-4 { - max-width: 200px !important; - margin: 0 auto !important; - display: flex !important; - justify-content: center !important; - } - - .task-message-content .task-message-content .my-4 img { - max-width: 200px !important; - width: 200px !important; - margin: 0 auto !important; - display: block !important; - } - - /* Paragraph spacing adjustment */ - .task-message-content p { - margin-bottom: 0.5rem !important; - margin-top: 0.25rem !important; - } - - .task-message-content .markdown-body p { - margin-bottom: 0.5rem !important; - margin-top: 0.25rem !important; - } - `}</style> </> ); } diff --git a/frontend/components/ui/statusBadge.tsx b/frontend/components/ui/statusBadge.tsx deleted file mode 100644 index 0f7c5382b..000000000 --- a/frontend/components/ui/statusBadge.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from "react"; - -interface StatusBadgeProps { - type: "success" | "warning" | "error" | "info" | "default"; - text: string; - icon?: React.ReactNode; - size?: "small" | "medium" | "large"; -} - -export const StatusBadge: React.FC<StatusBadgeProps> = ({ - type, - text, - icon, - size = "small", -}) => { - // Get styles based on type - const getStyleByType = (): React.CSSProperties => { - switch (type) { - case "success": - return { - color: "#52c41a", - borderColor: "#b7eb8f", - backgroundColor: "#f6ffed", - }; - case "warning": - return { - color: "#faad14", - borderColor: "#ffe58f", - backgroundColor: "#fffbe6", - }; - case "error": - return { - color: "#f5222d", - borderColor: "#ffa39e", - backgroundColor: "#fff1f0", - }; - case "info": - return { - color: "#1890ff", - borderColor: "#91d5ff", - backgroundColor: "#e6f7ff", - }; - default: - return { - color: "#d9d9d9", - borderColor: "#d9d9d9", - backgroundColor: "#fafafa", - }; - } - }; - - // Get size styles based on size - const getSizeStyle = (): React.CSSProperties => { - switch (size) { - case "large": - return { fontSize: "14px", padding: "4px 8px" }; - case "medium": - return { fontSize: "12px", padding: "2px 6px" }; - case "small": - default: - return { fontSize: "10px", padding: "1px 5px" }; - } - }; - - return ( - <span - className="inline-flex items-center rounded-full" - style={{ - ...getStyleByType(), - ...getSizeStyle(), - fontWeight: 500, - lineHeight: 1.4, - }} - > - {icon && <span className="mr-1">{icon}</span>} - {text} - </span> - ); -}; - -export default StatusBadge; diff --git a/frontend/lib/chat/chatAttachmentUtils.ts b/frontend/lib/chat/chatAttachmentUtils.ts new file mode 100644 index 000000000..fc442521a --- /dev/null +++ b/frontend/lib/chat/chatAttachmentUtils.ts @@ -0,0 +1,139 @@ +import type { Dispatch, SetStateAction } from "react"; +import { conversationService } from "@/services/conversationService"; +import { storageService } from "@/services/storageService"; +import { FilePreview } from "@/types/chat"; +import log from "@/lib/logger"; + +/** + * Handle file upload — create a local object URL for non-image files + * @returns Generated file ID + */ +export const handleFileUpload = ( + file: File, + setFileUrls: Dispatch<SetStateAction<Record<string, string>>>, + t: any +): string => { + const fileId = `file-${Date.now()}-${Math.random() + .toString(36) + .substring(7)}`; + + if (!file.type.startsWith("image/")) { + const fileUrl = URL.createObjectURL(file); + setFileUrls((prev) => ({ ...prev, [fileId]: fileUrl })); + } + + return fileId; +}; + +/** + * Handle image upload (reserved for future use) + */ +export const handleImageUpload = (file: File, t: any): void => {}; + +/** + * Upload attachments to storage service + * @returns Uploaded file URLs and object names + */ +export const uploadAttachments = async ( + attachments: FilePreview[], + t: any +): Promise<{ + uploadedFileUrls: Record<string, string>; + objectNames: Record<string, string>; + error?: string; +}> => { + if (attachments.length === 0) { + return { uploadedFileUrls: {}, objectNames: {} }; + } + + try { + const uploadResult = await storageService.uploadFiles( + attachments.map((attachment) => attachment.file) + ); + + const uploadedFileUrls: Record<string, string> = {}; + const objectNames: Record<string, string> = {}; + + if (uploadResult.success_count > 0) { + uploadResult.results.forEach((result) => { + if (result.success) { + uploadedFileUrls[result.file_name] = result.url; + objectNames[result.file_name] = result.object_name; + } + }); + } + + return { uploadedFileUrls, objectNames }; + } catch (error) { + log.error(t("chatPreprocess.fileUploadFailed"), error); + return { + uploadedFileUrls: {}, + objectNames: {}, + error: error instanceof Error ? error.message : String(error), + }; + } +}; + +/** + * Build attachment metadata objects for a chat message + */ +export const createMessageAttachments = ( + attachments: FilePreview[], + uploadedFileUrls: Record<string, string>, + fileUrls: Record<string, string> +): { type: string; name: string; size: number; url?: string }[] => { + return attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.file.name, + size: attachment.file.size, + url: + uploadedFileUrls[attachment.file.name] || + (attachment.type === "image" + ? attachment.previewUrl + : fileUrls[attachment.id]), + })); +}; + +/** + * Revoke all object URLs created for attachments to free browser memory + */ +export const cleanupAttachmentUrls = ( + attachments: FilePreview[], + fileUrls: Record<string, string> +): void => { + attachments.forEach((attachment) => { + if (attachment.previewUrl) { + URL.revokeObjectURL(attachment.previewUrl); + } + }); + + Object.values(fileUrls).forEach((url) => { + URL.revokeObjectURL(url); + }); +}; + +/** + * Preprocess attachment files before sending (currently a no-op, kept for future use) + * @returns Preprocessed query and processing status + */ +export const preprocessAttachments = async ( + content: string, + attachments: FilePreview[], + signal: AbortSignal, + onProgress: (data: any) => void, + t: any, + conversationId?: number +): Promise<{ + finalQuery: string; + success: boolean; + error?: string; + fileDescriptions?: Record<string, string>; +}> => { + if (attachments.length === 0) { + return { finalQuery: content, success: true }; + } + + // Preprocessing is currently disabled — return the original content unchanged. + // To re-enable, implement the streaming call to conversationService.preprocessFiles here. + return { finalQuery: content, success: true }; +}; diff --git a/frontend/app/[locale]/chat/internal/extractMsgFromHistoryResponse.tsx b/frontend/lib/chat/chatMessageExtractor.ts similarity index 87% rename from frontend/app/[locale]/chat/internal/extractMsgFromHistoryResponse.tsx rename to frontend/lib/chat/chatMessageExtractor.ts index 232b306c7..906ba59d8 100644 --- a/frontend/app/[locale]/chat/internal/extractMsgFromHistoryResponse.tsx +++ b/frontend/lib/chat/chatMessageExtractor.ts @@ -1,5 +1,3 @@ -"use client"; - import { chatConfig, MESSAGE_ROLES } from "@/const/chatConfig"; import { ApiMessage, @@ -11,17 +9,14 @@ import { } from "@/types/chat"; import log from "@/lib/logger"; -// function: process the user break tag +// Replace <user_break> tag with the localized natural language string const processSpecialTag = (content: string, t: any): string => { if (!content || typeof content !== "string") { return content; } - // check if the content is equal to <user_break> tag if (content == "<user_break>") { - // replace the content with the corresponding natural language according to the current language environment - const userBreakMessage = t("chatStreamHandler.userInterrupted"); - return userBreakMessage; + return t("chatStreamHandler.userInterrupted"); } return content; @@ -70,13 +65,11 @@ export function extractAssistantMsgFromResponse( dialog_msg.message.forEach((msg: ApiMessageItem) => { switch (msg.type) { case chatConfig.messageTypes.FINAL_ANSWER: { - // process the final_answer content and identify the user break tag finalAnswer += processSpecialTag(msg.content, t); break; } case chatConfig.messageTypes.STEP_COUNT: { - // create a new step steps.push({ id: `step-${steps.length + 1}`, title: msg.content.trim(), @@ -112,11 +105,9 @@ export function extractAssistantMsgFromResponse( case chatConfig.messageTypes.EXECUTION_LOGS: { const currentStep = steps[steps.length - 1]; if (currentStep) { - // create a new execution output const contentId = `execution-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; - currentStep.contents.push({ id: contentId, type: "execution", @@ -131,7 +122,6 @@ export function extractAssistantMsgFromResponse( case chatConfig.messageTypes.ERROR: { const currentStep = steps[steps.length - 1]; if (currentStep) { - // create the error content const contentId = `error-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; @@ -150,7 +140,6 @@ export function extractAssistantMsgFromResponse( const currentStep = steps[steps.length - 1]; if (currentStep) { try { - // parse placeholder content to get unit_id const placeholderData = JSON.parse(msg.content); const unitId = placeholderData.unit_id; @@ -159,14 +148,10 @@ export function extractAssistantMsgFromResponse( dialog_msg.search_unit_id && dialog_msg.search_unit_id[unitId.toString()] ) { - // get the corresponding search results according to unit_id const unitSearchResults = dialog_msg.search_unit_id[unitId.toString()]; - - // create the JSON string of search content const searchContent = JSON.stringify(unitSearchResults); - // add the search content as a search_content type message const contentId = `search-content-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; @@ -196,7 +181,6 @@ export function extractAssistantMsgFromResponse( case chatConfig.messageTypes.CARD: { const currentStep = steps[steps.length - 1]; if (currentStep) { - // create the card content const contentId = `card-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; @@ -214,7 +198,6 @@ export function extractAssistantMsgFromResponse( case chatConfig.messageTypes.TOOL: { const currentStep = steps[steps.length - 1]; if (currentStep) { - // create the tool call content const contentId = `tool-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; @@ -230,13 +213,11 @@ export function extractAssistantMsgFromResponse( } default: - // handle other types of messages break; } }); } - // create the formatted assistant message const formattedAssistantMsg: ChatMessageType = { id: `assistant-${index}-${Date.now()}`, role: MESSAGE_ROLES.ASSISTANT, @@ -274,14 +255,12 @@ export function extractUserMsgFromResponse( userContent = msgObj.content || ""; } - // handle the minio_files of the user message let userAttachments: MinioFileItem[] = []; if ( dialog_msg.minio_files && Array.isArray(dialog_msg.minio_files) && dialog_msg.minio_files.length > 0 ) { - // handle the minio_files userAttachments = dialog_msg.minio_files.map((item) => { return { type: item.type || "", @@ -299,11 +278,10 @@ export function extractUserMsgFromResponse( role: MESSAGE_ROLES.USER, message_id: dialog_msg.message_id, content: userContent, - opinion_flag: dialog_msg.opinion_flag, // user message does not have the like/dislike status + opinion_flag: dialog_msg.opinion_flag, timestamp: new Date(create_time), showRawContent: true, isComplete: true, - // add the attachments field, no longer use minio_files attachments: userAttachments.length > 0 ? userAttachments : undefined, }; return formattedUserMsg; diff --git a/frontend/lib/chatMessageExtractor.ts b/frontend/lib/chatMessageExtractor.ts new file mode 100644 index 000000000..906ba59d8 --- /dev/null +++ b/frontend/lib/chatMessageExtractor.ts @@ -0,0 +1,288 @@ +import { chatConfig, MESSAGE_ROLES } from "@/const/chatConfig"; +import { + ApiMessage, + SearchResult, + AgentStep, + ApiMessageItem, + ChatMessageType, + MinioFileItem, +} from "@/types/chat"; +import log from "@/lib/logger"; + +// Replace <user_break> tag with the localized natural language string +const processSpecialTag = (content: string, t: any): string => { + if (!content || typeof content !== "string") { + return content; + } + + if (content == "<user_break>") { + return t("chatStreamHandler.userInterrupted"); + } + + return content; +}; + +export function extractAssistantMsgFromResponse( + dialog_msg: ApiMessage, + index: number, + create_time: number, + t: any +) { + let searchResultsContent: SearchResult[] = []; + if ( + dialog_msg.search && + Array.isArray(dialog_msg.search) && + dialog_msg.search.length > 0 + ) { + searchResultsContent = dialog_msg.search.map((item) => ({ + title: item.title || t("extractMsg.unknownTitle"), + url: item.url || "#", + text: item.text || t("extractMsg.noContentDescription"), + published_date: item.published_date || "", + source_type: item.source_type || "", + filename: item.filename || "", + score: typeof item.score === "number" ? item.score : undefined, + score_details: item.score_details || {}, + tool_sign: item.tool_sign || "", + cite_index: typeof item.cite_index === "number" ? item.cite_index : -1, + })); + } + + // handle images + let imagesContent: string[] = []; + if ( + dialog_msg.picture && + Array.isArray(dialog_msg.picture) && + dialog_msg.picture.length > 0 + ) { + imagesContent = dialog_msg.picture; + } + + // extract the content of the Message + let finalAnswer = ""; + let steps: AgentStep[] = []; + if (dialog_msg.message && Array.isArray(dialog_msg.message)) { + dialog_msg.message.forEach((msg: ApiMessageItem) => { + switch (msg.type) { + case chatConfig.messageTypes.FINAL_ANSWER: { + finalAnswer += processSpecialTag(msg.content, t); + break; + } + + case chatConfig.messageTypes.STEP_COUNT: { + steps.push({ + id: `step-${steps.length + 1}`, + title: msg.content.trim(), + content: "", + expanded: false, + contents: [], + metrics: "", + thinking: { content: "", expanded: false }, + code: { content: "", expanded: false }, + output: { content: "", expanded: false }, + }); + break; + } + + case chatConfig.messageTypes.MODEL_OUTPUT_THINKING: { + const currentStep = steps[steps.length - 1]; + if (currentStep) { + const contentId = `model-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 7)}`; + currentStep.contents.push({ + id: contentId, + type: "model_output", + subType: "thinking", + content: msg.content, + expanded: true, + timestamp: Date.now(), + }); + } + break; + } + + case chatConfig.messageTypes.EXECUTION_LOGS: { + const currentStep = steps[steps.length - 1]; + if (currentStep) { + const contentId = `execution-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 7)}`; + currentStep.contents.push({ + id: contentId, + type: "execution", + content: msg.content, + expanded: true, + timestamp: Date.now(), + }); + } + break; + } + + case chatConfig.messageTypes.ERROR: { + const currentStep = steps[steps.length - 1]; + if (currentStep) { + const contentId = `error-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 7)}`; + currentStep.contents.push({ + id: contentId, + type: "error", + content: msg.content, + expanded: true, + timestamp: Date.now(), + }); + } + break; + } + + case chatConfig.messageTypes.SEARCH_CONTENT_PLACEHOLDER: { + const currentStep = steps[steps.length - 1]; + if (currentStep) { + try { + const placeholderData = JSON.parse(msg.content); + const unitId = placeholderData.unit_id; + + if ( + unitId && + dialog_msg.search_unit_id && + dialog_msg.search_unit_id[unitId.toString()] + ) { + const unitSearchResults = + dialog_msg.search_unit_id[unitId.toString()]; + const searchContent = JSON.stringify(unitSearchResults); + + const contentId = `search-content-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 7)}`; + currentStep.contents.push({ + id: contentId, + type: "search_content", + content: searchContent, + expanded: true, + timestamp: Date.now(), + }); + } + } catch (e) { + log.error(t("extractMsg.cannotParseSearchPlaceholder"), e); + } + } + break; + } + + case chatConfig.messageTypes.TOKEN_COUNT: { + const currentStep = steps[steps.length - 1]; + if (currentStep) { + currentStep.metrics = msg.content; + } + break; + } + + case chatConfig.messageTypes.CARD: { + const currentStep = steps[steps.length - 1]; + if (currentStep) { + const contentId = `card-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 7)}`; + currentStep.contents.push({ + id: contentId, + type: "card", + content: msg.content, + expanded: true, + timestamp: Date.now(), + }); + } + break; + } + + case chatConfig.messageTypes.TOOL: { + const currentStep = steps[steps.length - 1]; + if (currentStep) { + const contentId = `tool-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 7)}`; + currentStep.contents.push({ + id: contentId, + type: "executing", // use the existing executing type to represent the tool call + content: msg.content, + expanded: true, + timestamp: Date.now(), + }); + } + break; + } + + default: + break; + } + }); + } + + const formattedAssistantMsg: ChatMessageType = { + id: `assistant-${index}-${Date.now()}`, + role: MESSAGE_ROLES.ASSISTANT, + message_id: dialog_msg.message_id, + content: "", + opinion_flag: dialog_msg.opinion_flag, + timestamp: new Date(create_time), + steps: steps, + finalAnswer: finalAnswer, + agentRun: "", + isComplete: true, + showRawContent: false, + searchResults: searchResultsContent, + images: imagesContent, + attachments: undefined, + }; + return formattedAssistantMsg; +} + +export function extractUserMsgFromResponse( + dialog_msg: ApiMessage, + index: number, + create_time: number +) { + let userContent = ""; + if (Array.isArray(dialog_msg.message)) { + const stringMessage = dialog_msg.message.find( + (m: { type: string; content: string }) => m.type === "string" + ); + userContent = stringMessage?.content || ""; + } else if (typeof dialog_msg.message === "string") { + userContent = dialog_msg.message; + } else if (dialog_msg.message && typeof dialog_msg.message === "object") { + const msgObj = dialog_msg.message as { content?: string }; + userContent = msgObj.content || ""; + } + + let userAttachments: MinioFileItem[] = []; + if ( + dialog_msg.minio_files && + Array.isArray(dialog_msg.minio_files) && + dialog_msg.minio_files.length > 0 + ) { + userAttachments = dialog_msg.minio_files.map((item) => { + return { + type: item.type || "", + name: item.name || "", + size: item.size || 0, + object_name: item.object_name, + url: item.url, + description: item.description, + }; + }); + } + + const formattedUserMsg: ChatMessageType = { + id: `user-${index}-${Date.now()}`, + role: MESSAGE_ROLES.USER, + message_id: dialog_msg.message_id, + content: userContent, + opinion_flag: dialog_msg.opinion_flag, + timestamp: new Date(create_time), + showRawContent: true, + isComplete: true, + attachments: userAttachments.length > 0 ? userAttachments : undefined, + }; + return formattedUserMsg; +} diff --git a/frontend/styles/chat.css b/frontend/styles/chat.css new file mode 100644 index 000000000..85e4448c1 --- /dev/null +++ b/frontend/styles/chat.css @@ -0,0 +1,114 @@ +/* Chat module global styles - extracted from component inline styles */ + +/* TaskWindow animations */ +@keyframes blinkingDot { + 0% { + background-color: rgba(59, 130, 246, 0.5); + } + 50% { + background-color: rgba(79, 70, 229, 1); + } + 100% { + background-color: rgba(59, 130, 246, 0.5); + } +} + +.blinkingDot { + animation: blinkingDot 1.5s infinite ease-in-out; + background-color: rgba(79, 70, 229, 1); + box-shadow: 0 0 5px rgba(79, 70, 229, 0.5); +} + +@keyframes taskWindowEnter { + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-task-window { + animation: taskWindowEnter 0.5s ease-out forwards; +} + +/* TaskWindow content styles */ +.task-message-content .code-block-container { + max-width: 100% !important; + margin: 8px 0 !important; + overflow: visible !important; +} + +.task-message-content .code-block-content pre { + white-space: pre-wrap !important; + word-wrap: break-word !important; + word-break: break-word !important; + overflow-wrap: break-word !important; + max-width: 100% !important; + box-sizing: border-box !important; +} + +.task-message-content code:not(.code-block-content code) { + white-space: pre-wrap !important; + word-wrap: break-word !important; + word-break: break-word !important; + overflow-wrap: break-word !important; + max-width: 100% !important; +} + +.task-message-content .react-syntax-highlighter-line-number { + white-space: nowrap !important; +} + +.task-message-content { + max-width: 100% !important; + word-wrap: break-word !important; + word-break: break-word !important; +} + +.task-message-content * { + max-width: 100% !important; + box-sizing: border-box !important; +} + +.task-message-content .code-block-container * { + max-width: none !important; +} + +/* Diagram size overrides in task window */ +.task-message-content .my-4 { + max-width: 200px !important; + margin: 0 auto !important; + display: flex !important; + justify-content: center !important; +} + +.task-message-content .my-4 img { + max-width: 200px !important; + width: 200px !important; + margin: 0 auto !important; + display: block !important; +} + +.task-message-content .task-message-content .my-4 { + max-width: 200px !important; + margin: 0 auto !important; + display: flex !important; + justify-content: center !important; +} + +.task-message-content .task-message-content .my-4 img { + max-width: 200px !important; + width: 200px !important; + margin: 0 auto !important; + display: block !important; +} + +/* Paragraph spacing */ +.task-message-content p { + margin-bottom: 0.5rem !important; + margin-top: 0.25rem !important; +} + +.task-message-content .markdown-body p { + margin-bottom: 0.5rem !important; + margin-top: 0.25rem !important; +}