diff --git a/src/app/api/interactionAPI.js b/src/app/api/interactionAPI.js new file mode 100644 index 00000000..9846a990 --- /dev/null +++ b/src/app/api/interactionAPI.js @@ -0,0 +1,158 @@ +import { devLog } from '@/app/utils/logger'; +import { API_BASE_URL } from '@/app/config.js'; + +/** + * ExecutionMeta 정보들을 리스트 형태로 반환합니다. + * @param {Object} filters - 필터링 옵션 + * @param {string} [filters.interaction_id] - 특정 상호작용 ID로 필터링 (선택적) + * @param {string} [filters.workflow_id] - 특정 워크플로우 ID로 필터링 (선택적) + * @param {number} [filters.limit=100] - 반환할 최대 레코드 수 (기본값: 100) + * @returns {Promise} ExecutionMeta 데이터 리스트를 포함하는 프로미스 + * @throws {Error} API 요청이 실패하면 에러를 발생시킵니다. + */ +export const listInteractions = async (filters = {}) => { + try { + const { interaction_id, workflow_id, limit = 100 } = filters; + + // URL 파라미터 구성 + const params = new URLSearchParams(); + if (interaction_id) params.append('interaction_id', interaction_id); + if (workflow_id) params.append('workflow_id', workflow_id); + params.append('limit', limit.toString()); + + const response = await fetch( + `${API_BASE_URL}/interaction/list?${params}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.detail || `HTTP error! status: ${response.status}`, + ); + } + + const result = await response.json(); + devLog.log('Interaction list retrieved successfully:', result); + return result; + } catch (error) { + devLog.error('Failed to list interactions:', error); + throw error; + } +}; + +/** + * 새로운 워크플로우 실행을 시작하고 ExecutionMeta에 메타데이터를 저장합니다. + * @param {Object} requestData - 워크플로우 실행 요청 데이터 + * @param {string} requestData.workflow_name - 워크플로우 이름 + * @param {string} requestData.workflow_id - 워크플로우 ID + * @param {string} requestData.interaction_id - 상호작용 ID + * @param {string} [requestData.input_data] - 입력 데이터 (선택적) + * @returns {Promise} 워크플로우 실행 결과를 포함하는 프로미스 + * @throws {Error} API 요청이 실패하면 에러를 발생시킵니다. + */ +export const executeWorkflowNew = async (requestData) => { + try { + const { workflow_name, workflow_id, interaction_id, input_data } = requestData; + + // 필수 파라미터 검증 + if (!workflow_name || !workflow_id || !interaction_id) { + throw new Error('workflow_name, workflow_id, interaction_id는 필수 파라미터입니다.'); + } + + const requestBody = { + workflow_name, + workflow_id, + interaction_id, + ...(input_data && { input_data }), + }; + + devLog.log('Executing new workflow with data:', requestBody); + + const response = await fetch(`${API_BASE_URL}/interaction/new`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.detail || `HTTP error! status: ${response.status}`, + ); + } + + const result = await response.json(); + devLog.log('Workflow executed successfully:', result); + return result; + } catch (error) { + devLog.error('Failed to execute new workflow:', error); + throw error; + } +}; + +/** + * 특정 workflow_id에 대한 모든 상호작용을 조회합니다. + * @param {string} workflow_id - 워크플로우 ID + * @param {number} [limit=100] - 반환할 최대 레코드 수 + * @returns {Promise} 해당 워크플로우의 상호작용 리스트 + * @throws {Error} API 요청이 실패하면 에러를 발생시킵니다. + */ +export const getWorkflowInteractions = async (workflow_id, limit = 100) => { + try { + if (!workflow_id) { + throw new Error('workflow_id는 필수 파라미터입니다.'); + } + + return await listInteractions({ workflow_id, limit }); + } catch (error) { + devLog.error('Failed to get workflow interactions:', error); + throw error; + } +}; + +/** + * 특정 interaction_id에 대한 상호작용 정보를 조회합니다. + * @param {string} interaction_id - 상호작용 ID + * @returns {Promise} 해당 상호작용의 정보 + * @throws {Error} API 요청이 실패하면 에러를 발생시킵니다. + */ +export const getInteractionById = async (interaction_id) => { + try { + if (!interaction_id) { + throw new Error('interaction_id는 필수 파라미터입니다.'); + } + + return await listInteractions({ interaction_id, limit: 1 }); + } catch (error) { + devLog.error('Failed to get interaction by ID:', error); + throw error; + } +}; + +/** + * 고유한 interaction_id를 생성합니다. + * @param {string} [prefix='chat'] - ID 접두사 + * @returns {string} 생성된 고유 interaction_id + */ +export const generateInteractionId = (prefix = 'chat') => { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `${prefix}_${timestamp}_${random}`; +}; + +/** + * 워크플로우 이름에서 .json 확장자를 제거합니다. + * @param {string} workflowName - 워크플로우 이름 + * @returns {string} 확장자가 제거된 워크플로우 이름 + */ +export const normalizeWorkflowName = (workflowName) => { + return workflowName.replace('.json', ''); +}; diff --git a/src/app/api/workflowAPI.js b/src/app/api/workflowAPI.js index 6158cfd3..3964345d 100644 --- a/src/app/api/workflowAPI.js +++ b/src/app/api/workflowAPI.js @@ -258,17 +258,19 @@ export const getWorkflowPerformance = async (workflowName, workflowId) => { }; /** - * 특정 워크플로우의 성능 모니터링 데이터를 가져옵니다. + * 특정 워크플로우의 실행 기록 데이터를 가져옵니다. * @param {string} workflowName - 워크플로우 이름 (.json 확장자 제외) * @param {string} workflowId - 워크플로우 ID - * @returns {Promise} 성능 데이터를 포함하는 프로미스 + * @param {string} interactionId - 상호작용 ID (선택사항, 기본값: "default") + * @returns {Promise} 실행 기록 데이터를 포함하는 프로미스 * @throws {Error} API 요청이 실패하면 에러를 발생시킵니다. */ -export const getWorkflowIOLogs = async (workflowName, workflowId) => { +export const getWorkflowIOLogs = async (workflowName, workflowId, interactionId = 'default') => { try { const params = new URLSearchParams({ workflow_name: workflowName, workflow_id: workflowId, + interaction_id: interactionId, }); const response = await fetch( @@ -289,10 +291,52 @@ export const getWorkflowIOLogs = async (workflowName, workflowId) => { } const result = await response.json(); - devLog.log('Workflow performance data retrieved successfully:', result); + devLog.log('Workflow IO logs retrieved successfully:', result); return result; } catch (error) { - devLog.error('Failed to get workflow performance:', error); + devLog.error('Failed to get workflow IO logs:', error); + throw error; + } +}; + +/** + * 특정 워크플로우의 실행 기록 데이터를 삭제합니다. + * @param {string} workflowName - 워크플로우 이름 (.json 확장자 제외) + * @param {string} workflowId - 워크플로우 ID + * @param {string} interactionId - 상호작용 ID (기본값: "default") + * @returns {Promise} 삭제 결과 데이터를 포함하는 프로미스 + * @throws {Error} API 요청이 실패하면 에러를 발생시킵니다. + */ +export const deleteWorkflowIOLogs = async (workflowName, workflowId, interactionId = 'default') => { + try { + const params = new URLSearchParams({ + workflow_name: workflowName, + workflow_id: workflowId, + interaction_id: interactionId, + }); + + const response = await fetch( + `${API_BASE_URL}/workflow/io_logs?${params}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.detail || `HTTP error! status: ${response.status}`, + ); + } + + const result = await response.json(); + devLog.log('Workflow IO logs deleted successfully:', result); + return result; + } catch (error) { + devLog.error('Failed to delete workflow IO logs:', error); throw error; } }; @@ -309,17 +353,15 @@ export const executeWorkflowById = async ( workflowName, workflowId, inputData = '', + interaction_id = 'default', ) => { try { const body = { workflow_name: workflowName, workflow_id: workflowId, input_data: inputData || '', + interaction_id: interaction_id || 'default', }; - devLog.log('ExecuteWorkflowById called with:'); - devLog.log('- workflowName:', workflowName); - devLog.log('- workflowId:', workflowId); - devLog.log('- inputData:', inputData); const response = await fetch( `${API_BASE_URL}/workflow/execute/based_id`, { @@ -346,3 +388,43 @@ export const executeWorkflowById = async ( throw error; } }; + +/** + * 워크플로우의 성능 데이터를 삭제합니다. + * @param {string} workflowName - 워크플로우 이름 (.json 확장자 제외) + * @param {string} workflowId - 워크플로우 ID + * @returns {Promise} 삭제 결과를 포함하는 프로미스 + * @throws {Error} API 요청이 실패하면 에러를 발생시킵니다. + */ +export const deleteWorkflowPerformance = async (workflowName, workflowId) => { + try { + const params = new URLSearchParams({ + workflow_name: workflowName, + workflow_id: workflowId, + }); + + const response = await fetch( + `${API_BASE_URL}/workflow/performance?${params}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.detail || `HTTP error! status: ${response.status}`, + ); + } + + const result = await response.json(); + devLog.log('Workflow performance data deleted successfully:', result); + return result; + } catch (error) { + devLog.error('Failed to delete workflow performance data:', error); + throw error; + } +}; diff --git a/src/app/chat/assets/ChatHistory.module.scss b/src/app/chat/assets/ChatHistory.module.scss new file mode 100644 index 00000000..3531a612 --- /dev/null +++ b/src/app/chat/assets/ChatHistory.module.scss @@ -0,0 +1,358 @@ +@use "sass:color"; + +// Color Variables +$primary-blue: #2563eb; +$primary-green: #059669; +$primary-red: #dc2626; +$gray-50: #f9fafb; +$gray-100: #f3f4f6; +$gray-200: #e5e7eb; +$gray-300: #d1d5db; +$gray-400: #9ca3af; +$gray-500: #6b7280; +$gray-600: #4b5563; +$gray-700: #374151; +$gray-800: #1f2937; +$gray-900: #111827; +$white: #ffffff; + +.container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: $white; + border-radius: 8px; + overflow: hidden; +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 2rem; + border-bottom: 1px solid $gray-200; + background: $gray-50; +} + +.headerInfo { + flex: 1; + + h2 { + font-size: 1.875rem; + font-weight: 700; + color: $gray-900; + margin: 0 0 0.5rem 0; + } + + p { + color: $gray-600; + margin: 0; + line-height: 1.6; + } +} + +.refreshButton { + display: flex; + align-items: center; + gap: 0.5rem; + background: $primary-blue; + color: $white; + border: none; + padding: 0.75rem 1.25rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: color.scale($primary-blue, $lightness: -18.75%); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(37, 99, 235, 0.25); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + &.loading svg { + animation: spin 1s linear infinite; + } + + svg { + width: 1rem; + height: 1rem; + } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.content { + flex: 1; + overflow-y: auto; + padding: 2rem; +} + +.loadingState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; + color: $gray-500; + + p { + margin-top: 1rem; + font-size: 1rem; + } +} + +.loadingSpinner { + width: 32px; + height: 32px; + border: 3px solid $gray-200; + border-top: 3px solid $primary-blue; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.errorState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; + color: $primary-red; + text-align: center; + + p { + margin-bottom: 1.5rem; + font-size: 1rem; + } +} + +.retryButton { + background: $primary-red; + color: $white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + background: color.scale($primary-red, $lightness: -18.75%); + transform: translateY(-1px); + } +} + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; + color: $gray-500; + text-align: center; + + .emptyIcon { + width: 4rem; + height: 4rem; + margin-bottom: 1.5rem; + color: $gray-400; + } + + h3 { + margin: 0 0 0.5rem 0; + font-size: 1.25rem; + font-weight: 600; + color: $gray-700; + } + + p { + margin: 0; + font-size: 1rem; + line-height: 1.5; + } +} + +.chatGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; +} + +.chatCard { + background: $white; + border: 1px solid $gray-200; + border-radius: 12px; + padding: 1.5rem; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + &:hover { + border-color: $primary-blue; + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.15); + transform: translateY(-2px); + } +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.workflowName { + font-size: 1.125rem; + font-weight: 600; + color: $gray-900; + margin: 0; + line-height: 1.3; + max-width: 70%; + word-break: break-word; +} + +.chatDate { + font-size: 0.875rem; + color: $gray-500; + flex-shrink: 0; + background: $gray-100; + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.cardMeta { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.metaItem { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: $gray-600; + + svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: $gray-400; + } + + span { + line-height: 1; + } +} + +.interactionId { + font-family: monospace; + background: $gray-100; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem !important; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardActions { + display: flex; + gap: 0.75rem; +} + +.selectButton { + flex: 1; + background: $white; + color: $primary-blue; + border: 2px solid $primary-blue; + padding: 0.75rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + background: $primary-blue; + color: $white; + transform: translateY(-1px); + } +} + +.continueButton { + flex: 2; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: $primary-green; + color: $white; + border: none; + padding: 0.75rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + background: color.scale($primary-green, $lightness: -18.75%); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(5, 150, 105, 0.25); + } + + svg { + width: 1rem; + height: 1rem; + } +} + +// 반응형 디자인 +@media (max-width: 768px) { + .header { + padding: 1.5rem; + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .content { + padding: 1rem; + } + + .chatGrid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .chatCard { + padding: 1rem; + } + + .workflowName { + font-size: 1rem; + max-width: 100%; + } + + .cardActions { + flex-direction: column; + } + + .selectButton, + .continueButton { + flex: 1; + } +} diff --git a/src/app/chat/assets/ChatInterface.module.scss b/src/app/chat/assets/ChatInterface.module.scss index 33df9d2c..566ea17f 100644 --- a/src/app/chat/assets/ChatInterface.module.scss +++ b/src/app/chat/assets/ChatInterface.module.scss @@ -378,6 +378,52 @@ $white: #ffffff; font-weight: 500; } +// Welcome Actions for NewChatInterface +.welcomeActions { + margin-top: 2rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; +} + +.suggestionChips { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + max-width: 400px; +} + +.suggestionChip { + padding: 0.75rem 1.25rem; + background: $gray-50; + border: 1px solid $gray-300; + border-radius: 20px; + color: $gray-700; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: $primary-blue; + color: $white; + border-color: $primary-blue; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(37, 99, 235, 0.15); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + &:active { + transform: translateY(0); + } +} + // Responsive Design @media (max-width: 768px) { .header { diff --git a/src/app/chat/components/ChatContent.tsx b/src/app/chat/components/ChatContent.tsx index b504727d..33bcc638 100644 --- a/src/app/chat/components/ChatContent.tsx +++ b/src/app/chat/components/ChatContent.tsx @@ -1,26 +1,85 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; import styles from '@/app/chat/assets/ChatContent.module.scss'; import { LuWorkflow } from "react-icons/lu"; import { IoChatbubblesOutline } from "react-icons/io5"; import WorkflowSelection from './WorkflowSelection'; import ChatInterface from './ChatInterface'; +import NewChatInterface from './NewChatInterface'; const ChatContent: React.FC = () => { - const [currentView, setCurrentView] = useState<'welcome' | 'workflow' | 'chat'>('welcome'); + const searchParams = useSearchParams(); + const [currentView, setCurrentView] = useState<'welcome' | 'workflow' | 'newChat' | 'existingChat'>('welcome'); const [selectedWorkflow, setSelectedWorkflow] = useState(null); + const [chatType, setChatType] = useState<'new' | 'existing'>('new'); + const [existingChatData, setExistingChatData] = useState(null); + + // URL 파라미터에서 기존 채팅 정보 확인 + useEffect(() => { + const mode = searchParams.get('mode'); + const interactionId = searchParams.get('interaction_id'); + const workflowId = searchParams.get('workflow_id'); + const workflowName = searchParams.get('workflow_name'); + + if (mode === 'existing' && interactionId && workflowId && workflowName) { + // 기존 채팅 정보를 설정하고 바로 채팅 화면으로 이동 + const existingWorkflow = { + id: workflowId, + name: workflowName, + filename: workflowName, + author: 'Unknown', + nodeCount: 0, + status: 'active' as const, + }; + + setExistingChatData({ + interactionId, + workflowId, + workflowName, + }); + + setSelectedWorkflow(existingWorkflow); + setChatType('existing'); + setCurrentView('existingChat'); + } + }, [searchParams]); const handleWorkflowSelect = (workflow: any) => { setSelectedWorkflow(workflow); - setCurrentView('chat'); + // 새로운 채팅으로 시작 (항상 NewChatInterface 사용) + setChatType('new'); + setCurrentView('newChat'); + }; + + const handleExistingChatSelect = (workflow: any) => { + setSelectedWorkflow(workflow); + // 기존 채팅 계속하기 (ChatInterface 사용) + setChatType('existing'); + setCurrentView('existingChat'); }; - // 채팅 화면 - if (currentView === 'chat' && selectedWorkflow) { + // 새로운 채팅 화면 (NewChatInterface) + if (currentView === 'newChat' && selectedWorkflow) { + return ( +
+
+ setCurrentView('workflow')} + /> +
+
+ ); + } + + // 기존 채팅 화면 (ChatInterface) + if (currentView === 'existingChat' && selectedWorkflow) { return (
setCurrentView('workflow')} />
diff --git a/src/app/chat/components/ChatHistory.tsx b/src/app/chat/components/ChatHistory.tsx new file mode 100644 index 00000000..d65319cf --- /dev/null +++ b/src/app/chat/components/ChatHistory.tsx @@ -0,0 +1,201 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import { + FiMessageSquare, + FiClock, + FiRefreshCw, + FiUser, + FiPlay, +} from 'react-icons/fi'; +import { listInteractions } from '@/app/api/interactionAPI'; +import { devLog } from '@/app/utils/logger'; +import styles from '@/app/chat/assets/ChatHistory.module.scss'; +import toast from 'react-hot-toast'; + +interface ExecutionMeta { + id: string; + interaction_id: string; + workflow_id: string; + workflow_name: string; + interaction_count: number; + metadata: any; + created_at: string; + updated_at: string; +} + +interface ChatHistoryProps { + onSelectChat: (executionMeta: ExecutionMeta) => void; +} + +const ChatHistory: React.FC = ({ onSelectChat }) => { + const [chatList, setChatList] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + loadChatHistory(); + }, []); + + const loadChatHistory = async () => { + try { + setLoading(true); + setError(null); + + const result = await listInteractions({ limit: 50 }); + setChatList((result as any).execution_meta_list || []); + + devLog.log('Chat history loaded:', result); + } catch (err) { + setError('채팅 기록을 불러오는데 실패했습니다.'); + devLog.error('Failed to load chat history:', err); + toast.error('채팅 기록을 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return date.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit' + }); + } else if (diffDays === 1) { + return '어제'; + } else if (diffDays < 7) { + return `${diffDays}일 전`; + } else { + return date.toLocaleDateString('ko-KR', { + month: 'short', + day: 'numeric' + }); + } + }; + + const handleChatSelect = (chat: ExecutionMeta) => { + // 선택한 채팅을 현재 채팅으로 설정 + const currentChatData = { + interactionId: chat.interaction_id, + workflowId: chat.workflow_id, + workflowName: chat.workflow_name, + startedAt: chat.created_at, + }; + localStorage.setItem('currentChatData', JSON.stringify(currentChatData)); + + onSelectChat(chat); + toast.success(`"${chat.workflow_name}" 대화를 현재 채팅으로 설정했습니다!`); + }; + + const handleContinueChat = (chat: ExecutionMeta) => { + // 채팅을 현재 채팅으로 설정 + const currentChatData = { + interactionId: chat.interaction_id, + workflowId: chat.workflow_id, + workflowName: chat.workflow_name, + startedAt: chat.created_at, + }; + localStorage.setItem('currentChatData', JSON.stringify(currentChatData)); + + // 현재 채팅 모드로 이동 + window.location.href = '/chat?mode=current'; + }; + + return ( +
+
+
+

기존 채팅 불러오기

+

이전 대화를 선택하여 계속하거나 새로운 창에서 열어보세요.

+
+ +
+ +
+ {loading && ( +
+
+

채팅 기록을 불러오는 중...

+
+ )} + + {error && ( +
+

{error}

+ +
+ )} + + {!loading && !error && chatList.length === 0 && ( +
+ +

아직 채팅 기록이 없습니다

+

새로운 대화를 시작해보세요!

+
+ )} + + {!loading && !error && chatList.length > 0 && ( +
+ {chatList.map((chat) => ( +
+
+

+ {chat.workflow_name} +

+ + {formatDate(chat.updated_at)} + +
+ +
+
+ + {chat.interaction_count}회 대화 +
+
+ + + {chat.interaction_id} + +
+
+ +
+ + +
+
+ ))} +
+ )} +
+
+ ); +}; + +export default ChatHistory; diff --git a/src/app/chat/components/ChatInterface.tsx b/src/app/chat/components/ChatInterface.tsx index 2b120c7f..32d25fcc 100644 --- a/src/app/chat/components/ChatInterface.tsx +++ b/src/app/chat/components/ChatInterface.tsx @@ -35,9 +35,14 @@ interface IOLog { interface ChatInterfaceProps { workflow: Workflow; onBack: () => void; + existingChatData?: { + interactionId: string; + workflowId: string; + workflowName: string; + } | null; } -const ChatInterface: React.FC = ({ workflow, onBack }) => { +const ChatInterface: React.FC = ({ workflow, onBack, existingChatData }) => { const [ioLogs, setIOLogs] = useState([]); const [loading, setLoading] = useState(true); const [executing, setExecuting] = useState(false); @@ -65,7 +70,13 @@ const ChatInterface: React.FC = ({ workflow, onBack }) => { try { setLoading(true); setError(null); - const logs = await getWorkflowIOLogs(workflow.name, workflow.id); + + // existingChatData가 있으면 해당 interaction_id의 로그를 가져옴 + const interactionId = existingChatData?.interactionId || 'default'; + const workflowName = existingChatData?.workflowName || workflow.name; + const workflowId = existingChatData?.workflowId || workflow.id; + + const logs = await getWorkflowIOLogs(workflowName, workflowId, interactionId); setIOLogs((logs as any).in_out_logs || []); setPendingLogId(null); } catch (err) { @@ -112,10 +123,16 @@ const ChatInterface: React.FC = ({ workflow, onBack }) => { setInputMessage(''); try { + // existingChatData가 있으면 해당 interaction_id 사용 + const interactionId = existingChatData?.interactionId || 'default'; + const workflowName = existingChatData?.workflowName || workflow.name; + const workflowId = existingChatData?.workflowId || workflow.id; + const result: any = await executeWorkflowById( - workflow.name, - workflow.id, + workflowName, + workflowId, currentMessage, + interactionId, ); // 결과로 임시 메시지 업데이트 @@ -133,6 +150,17 @@ const ChatInterface: React.FC = ({ workflow, onBack }) => { ), ); setPendingLogId(null); + + // 기존 채팅 데이터가 있는 경우 localStorage 업데이트 + if (existingChatData) { + const currentChatData = { + interactionId: existingChatData.interactionId, + workflowId: existingChatData.workflowId, + workflowName: existingChatData.workflowName, + startedAt: new Date().toISOString(), + }; + localStorage.setItem('currentChatData', JSON.stringify(currentChatData)); + } } catch (err) { // 에러로 임시 메시지 업데이트 setIOLogs((prev) => @@ -170,7 +198,7 @@ const ChatInterface: React.FC = ({ workflow, onBack }) => {

{workflow.name}

-

AI 워크플로우와 대화하세요

+

기존 대화를 계속하세요

@@ -192,8 +220,9 @@ const ChatInterface: React.FC = ({ workflow, onBack }) => { {ioLogs.length === 0 ? (
-

첫 대화를 시작해보세요!

-

"{workflow.name}" 워크플로우가 준비되었습니다.

+

대화 기록이 없습니다

+

"{workflow.name}" 워크플로우의 이전 대화를 불러올 수 없습니다.

+

새로운 대화를 시작해보세요.

) : ( ioLogs.map((log) => ( diff --git a/src/app/chat/components/CurrentChatInterface.tsx b/src/app/chat/components/CurrentChatInterface.tsx new file mode 100644 index 00000000..f1074802 --- /dev/null +++ b/src/app/chat/components/CurrentChatInterface.tsx @@ -0,0 +1,90 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import ChatInterface from './ChatInterface'; +import { FiMessageSquare } from 'react-icons/fi'; +import styles from '@/app/chat/assets/ChatInterface.module.scss'; +import chatContentStyles from '@/app/chat/assets/ChatContent.module.scss'; + +interface CurrentChatInterfaceProps { + onBack?: () => void; +} + +const CurrentChatInterface: React.FC = ({ onBack }) => { + const [currentChatData, setCurrentChatData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // localStorage에서 현재 채팅 정보 가져오기 + const savedChatData = localStorage.getItem('currentChatData'); + if (savedChatData) { + try { + const chatData = JSON.parse(savedChatData); + setCurrentChatData(chatData); + } catch (error) { + console.error('Failed to parse current chat data:', error); + } + } + setLoading(false); + }, []); + + if (loading) { + return ( +
+
+
+
+
+

현재 채팅을 불러오는 중...

+
+
+
+
+ ); + } + + if (!currentChatData) { + return ( +
+
+
+
+ +

진행 중인 채팅이 없습니다

+

새로운 채팅을 시작해보세요!

+
+
+
+
+ ); + } + + // 현재 채팅 데이터로 워크플로우 객체 생성 + const workflow = { + id: currentChatData.workflowId, + name: currentChatData.workflowName, + filename: currentChatData.workflowName, + author: 'Unknown', + nodeCount: 0, + status: 'active' as const, + }; + + const existingChatData = { + interactionId: currentChatData.interactionId, + workflowId: currentChatData.workflowId, + workflowName: currentChatData.workflowName, + }; + + return ( +
+
+ {})} + /> +
+
+ ); +}; + +export default CurrentChatInterface; diff --git a/src/app/chat/components/NewChatInterface.tsx b/src/app/chat/components/NewChatInterface.tsx new file mode 100644 index 00000000..92f882ea --- /dev/null +++ b/src/app/chat/components/NewChatInterface.tsx @@ -0,0 +1,287 @@ +'use client'; +import React, { useState, useRef } from 'react'; +import { + FiSend, + FiArrowLeft, + FiMessageSquare, + FiClock, +} from 'react-icons/fi'; +import styles from '@/app/chat/assets/ChatInterface.module.scss'; +import { executeWorkflowNew, generateInteractionId, normalizeWorkflowName } from '@/app/api/interactionAPI'; +import toast from 'react-hot-toast'; + +interface Workflow { + id: string; + name: string; + description?: string; + createdAt?: string; + lastModified?: string; + author: string; + nodeCount: number; + status: 'active' | 'draft' | 'archived'; + filename?: string; + error?: string; +} + +interface IOLog { + log_id: number | string; + workflow_name: string; + workflow_id: string; + input_data: string; + output_data: string; + updated_at: string; +} + +interface NewChatInterfaceProps { + workflow: Workflow; + onBack: () => void; +} + +const NewChatInterface: React.FC = ({ workflow, onBack }) => { + const [ioLogs, setIOLogs] = useState([]); + const [executing, setExecuting] = useState(false); + const [error, setError] = useState(null); + const [inputMessage, setInputMessage] = useState(''); + const [pendingLogId, setPendingLogId] = useState(null); + const [isFirstMessage, setIsFirstMessage] = useState(true); + const [interactionId] = useState(() => generateInteractionId()); + + const messagesRef = useRef(null); + + const scrollToBottom = () => { + if (messagesRef.current) { + messagesRef.current.scrollTop = messagesRef.current.scrollHeight; + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString('ko-KR', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const executeWorkflow = async () => { + if (!inputMessage.trim()) { + return; + } + + setError(null); + setExecuting(true); + const tempId = `pending-${Date.now()}`; + setPendingLogId(tempId); + + // 첫 메시지 후에는 일반 대화 모드로 전환 + if (isFirstMessage) { + setIsFirstMessage(false); + } + + // 임시 메시지 추가 + setIOLogs((prev) => [ + ...prev, + { + log_id: tempId, + workflow_name: workflow.name, + workflow_id: workflow.id, + input_data: inputMessage, + output_data: '', + updated_at: new Date().toISOString(), + }, + ]); + + const currentMessage = inputMessage; + setInputMessage(''); + + // 스크롤을 하단으로 이동 + setTimeout(scrollToBottom, 100); + + try { + const result: any = await executeWorkflowNew({ + workflow_name: normalizeWorkflowName(workflow.name), + workflow_id: workflow.id, + interaction_id: interactionId, + input_data: currentMessage, + }); + + // 첫 번째 메시지 전송 시 현재 채팅 데이터를 localStorage에 저장 + if (isFirstMessage) { + const currentChatData = { + interactionId: interactionId, + workflowId: workflow.id, + workflowName: normalizeWorkflowName(workflow.name), + startedAt: new Date().toISOString(), + }; + localStorage.setItem('currentChatData', JSON.stringify(currentChatData)); + setIsFirstMessage(false); + } + + // 결과로 임시 메시지 업데이트 + setIOLogs((prev) => + prev.map((log) => + String(log.log_id) === tempId + ? { + ...log, + output_data: result.outputs + ? JSON.stringify(result.outputs) + : result.message || '처리 완료', + updated_at: new Date().toISOString(), + } + : log, + ), + ); + setPendingLogId(null); + setTimeout(scrollToBottom, 100); + } catch (err) { + // 에러로 임시 메시지 업데이트 + setIOLogs((prev) => + prev.map((log) => + String(log.log_id) === tempId + ? { + ...log, + output_data: err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.', + updated_at: new Date().toISOString(), + } + : log, + ), + ); + setPendingLogId(null); + toast.error('메시지 처리 중 오류가 발생했습니다.'); + setTimeout(scrollToBottom, 100); + } finally { + setExecuting(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && !executing) { + e.preventDefault(); + executeWorkflow(); + } + }; + + return ( +
+ {/* Header */} +
+
+ +
+

{workflow.name}

+

새로운 대화를 시작하세요

+
+
+
+ + {ioLogs.length}개의 대화 +
+
+ + {/* Chat Area */} +
+
+ {isFirstMessage ? ( +
+ +

첫 대화를 시작해보세요!

+

"{workflow.name}" 워크플로우가 준비되었습니다.

+
+
+ + + +
+
+
+ ) : ( + ioLogs.map((log) => ( +
+ {/* User Message */} +
+
+ {log.input_data} +
+
+ {formatDate(log.updated_at)} +
+
+ + {/* Bot Message */} +
+
+ {String(log.log_id) === pendingLogId && executing && !log.output_data ? ( +
+ + + +
+ ) : ( + log.output_data + )} +
+
+
+ )) + )} +
+ + {/* Input Area */} +
+
+ setInputMessage(e.target.value)} + onKeyPress={handleKeyPress} + disabled={executing} + className={styles.messageInput} + /> + +
+ {executing && ( +

+ 워크플로우를 실행 중입니다... +

+ )} + {error && ( +

{error}

+ )} +
+
+
+ ); +}; + +export default NewChatInterface; diff --git a/src/app/chat/components/WorkflowSelection.tsx b/src/app/chat/components/WorkflowSelection.tsx index 2c887b01..87ac54d7 100644 --- a/src/app/chat/components/WorkflowSelection.tsx +++ b/src/app/chat/components/WorkflowSelection.tsx @@ -137,7 +137,7 @@ const WorkflowSelection: React.FC = ({ onBack, onSelectW

워크플로우 선택

-

채팅에 사용할 워크플로우를 선택하세요.

+

새로운 대화를 시작할 워크플로우를 선택하세요.

diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index cdcab9ed..93e25fe2 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,27 +1,78 @@ 'use client'; -import React from 'react'; -import { useRouter } from 'next/navigation'; +import React, { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import Sidebar from '@/app/main/components/Sidebar'; import ChatContent from '@/app/chat/components/ChatContent'; +import ChatHistory from '@/app/chat/components/ChatHistory'; +import CurrentChatInterface from '@/app/chat/components/CurrentChatInterface'; import { getSidebarItems, createItemClickHandler } from '@/app/_common/components/sidebarConfig'; import styles from '@/app/main/assets/MainPage.module.scss'; const ChatPage: React.FC = () => { const router = useRouter(); + const searchParams = useSearchParams(); + const [activeMode, setActiveMode] = useState<'new-chat' | 'chat-history' | 'current-chat'>('chat-history'); const sidebarItems = getSidebarItems(); - const handleItemClick = createItemClickHandler(router); + + // URL 파라미터 확인해서 기존 채팅 모드인지 확인 + useEffect(() => { + const mode = searchParams.get('mode'); + const interactionId = searchParams.get('interaction_id'); + + if (mode === 'current') { + // 현재 채팅 모드로 이동 + setActiveMode('current-chat'); + } else if (mode === 'existing' && interactionId) { + // 기존 채팅 계속하기 모드 - ChatContent로 이동 + setActiveMode('new-chat'); + } else { + // 기본적으로 채팅 히스토리 표시 + setActiveMode('chat-history'); + } + }, [searchParams]); + + // 채팅 모드 변경 핸들러 + const handleChatModeClick = (mode: string) => { + if (mode === 'new-chat' || mode === 'chat-history' || mode === 'current-chat') { + setActiveMode(mode); + } else { + // 다른 메뉴 아이템들은 기존 핸들러 사용 + const handleItemClick = createItemClickHandler(router); + handleItemClick(mode); + } + }; + + // 채팅 선택 처리 + const handleChatSelect = (executionMeta: any) => { + // 선택된 채팅을 현재 채팅으로 설정 후 current-chat 모드로 전환 + console.log('Selected chat:', executionMeta); + setActiveMode('current-chat'); + }; + + const renderContent = () => { + switch (activeMode) { + case 'new-chat': + return ; + case 'chat-history': + return ; + case 'current-chat': + return ; + default: + return ; + } + }; return (
- + {renderContent()}
); diff --git a/src/app/main/assets/Executor.module.scss b/src/app/main/assets/Executor.module.scss index d657f4fd..055c94af 100644 --- a/src/app/main/assets/Executor.module.scss +++ b/src/app/main/assets/Executor.module.scss @@ -1,15 +1,164 @@ -.loadingSpinner { - width: 32px; - height: 32px; - border: 3px solid #e9ecef; - border-top: 3px solid #007bff; - border-radius: 50%; - animation: spin 1s linear infinite; +@use "sass:color"; + +// Color Variables +$primary-blue: #2563eb; +$primary-green: #059669; +$primary-yellow: #d97706; +$primary-red: #dc2626; +$gray-50: #f9fafb; +$gray-100: #f3f4f6; +$gray-200: #e5e7eb; +$gray-300: #d1d5db; +$gray-400: #9ca3af; +$gray-500: #6b7280; +$gray-600: #4b5563; +$gray-700: #374151; +$gray-900: #111827; +$white: #ffffff; + +.container { + max-width: 1200px; + margin: 0 auto; +} + +// Header +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +.headerInfo { + h2 { + font-size: 1.875rem; + font-weight: 700; + color: $gray-900; + margin: 0 0 0.5rem 0; + } + + p { + color: $gray-600; + margin: 0; + line-height: 1.6; + } +} + +.headerActions { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +// 새로고침 버튼 스타일 +.refreshButton { + background: transparent; + border: 1px solid $gray-300; + border-radius: 6px; + padding: 0.5rem; + cursor: pointer; + color: $gray-600; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + + &:hover:not(:disabled) { + background: $gray-50; + border-color: $primary-blue; + color: $primary-blue; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + svg { + width: 16px; + height: 16px; + } +} + +// 스피닝 애니메이션 +.spinning { + animation: spin 1s linear infinite; } @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +// Executor 헤더 +.executorHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; + + h3 { + font-size: 1.875rem; + font-weight: 700; + color: $gray-900; + margin: 0 0 0.5rem 0; + } +} + +.logCount { + display: flex; + align-items: center; + gap: 0.375rem; + color: $gray-500; + font-size: 0.875rem; + padding: 0.375rem 0.75rem; + background: $gray-50; + border-radius: 6px; + border: 1px solid $gray-200; + + svg { + width: 0.875rem; + height: 0.875rem; + } +} + +.clearLogsBtn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: $white; + border: 1px solid $gray-300; + border-radius: 6px; + color: $gray-600; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: #fef2f2; + border-color: #fca5a5; + color: #dc2626; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + background: $gray-100; + } + + svg { + width: 1rem; + height: 1rem; + } } .executorPanel { @@ -30,7 +179,7 @@ align-items: center; justify-content: center; height: 100%; - color: #6c757d; + color: $gray-500; gap: 1rem; span { @@ -46,11 +195,11 @@ justify-content: center; height: 100%; text-align: center; - color: #6c757d; + color: $gray-500; h3 { margin-bottom: 0.5rem; - color: #495057; + color: $gray-700; } p { @@ -65,38 +214,6 @@ flex-direction: column; } -.executorHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - padding-bottom: 1rem; - border-bottom: 2px solid #e9ecef; - - h3 { - margin: 0; - color: #2c3e50; - font-size: 1.4rem; - font-weight: 600; - flex: 1; - } -} - -.logCount { - display: flex; - align-items: center; - gap: 0.5rem; - color: #6c757d; - font-size: 0.9rem; - font-weight: 500; - - svg { - width: 16px; - height: 16px; - color: #007bff; - } -} - .executorContainer { flex: 1; display: flex; diff --git a/src/app/main/assets/Monitor.module.scss b/src/app/main/assets/Monitor.module.scss index 237204ef..accae278 100644 --- a/src/app/main/assets/Monitor.module.scss +++ b/src/app/main/assets/Monitor.module.scss @@ -74,6 +74,56 @@ flex-direction: column; } +.headerActions { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.recordCount { + display: flex; + align-items: center; + color: #6c757d; + font-size: 0.875rem; + padding: 0.375rem 0.75rem; + background: #f8f9fa; + border-radius: 6px; + border: 1px solid #e9ecef; +} + +.clearRecordsBtn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: white; + border: 1px solid #dc3545; + border-radius: 6px; + color: #dc3545; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: #f8d7da; + border-color: #c82333; + color: #721c24; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + background: #f8f9fa; + } + + svg { + width: 1rem; + height: 1rem; + } +} + .performanceHeader { display: flex; justify-content: space-between; @@ -90,7 +140,6 @@ flex: 1; } } - .refreshButton { background: #007bff; color: white; diff --git a/src/app/main/components/Executor.tsx b/src/app/main/components/Executor.tsx index d8748d70..99a32b09 100644 --- a/src/app/main/components/Executor.tsx +++ b/src/app/main/components/Executor.tsx @@ -6,12 +6,15 @@ import { FiMessageSquare, FiSend, FiClock, + FiTrash2, } from 'react-icons/fi'; import { listWorkflowsDetail, getWorkflowIOLogs, executeWorkflowById, + deleteWorkflowIOLogs, } from '@/app/api/workflowAPI'; +import toast from 'react-hot-toast'; import styles from '@/app/main/assets/Executor.module.scss'; interface Workflow { @@ -43,6 +46,7 @@ const Executor: React.FC = ({ workflow }) => { const [ioLogs, setIOLogs] = useState([]); const [executorLoading, setExecutorLoading] = useState(false); const [executing, setExecuting] = useState(false); + const [deletingLogs, setDeletingLogs] = useState(false); const [error, setError] = useState(null); const [inputMessage, setInputMessage] = useState(''); const [pendingLogId, setPendingLogId] = useState(null); @@ -93,6 +97,136 @@ const Executor: React.FC = ({ workflow }) => { return new Date(dateString).toLocaleString('ko-KR'); }; + // Handle log deletion with Toast confirmation (exactly like CompletedWorkflows) + const clearWorkflowLogs = async () => { + if (!selectedWorkflow) { + return; + } + + const workflowName = selectedWorkflow.filename.replace('.json', ''); + + const confirmToast = toast( + (t) => ( +
+
+ 실행 로그 삭제 +
+
+ "{workflowName}" 워크플로우의 모든 실행 로그를 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + +
+
+ ), + { + duration: Infinity, + style: { + maxWidth: '420px', + padding: '20px', + backgroundColor: '#f9fafb', + border: '2px solid #374151', + borderRadius: '12px', + boxShadow: + '0 8px 25px rgba(0, 0, 0, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)', + color: '#374151', + fontFamily: 'system-ui, -apple-system, sans-serif', + }, + }, + ); + }; + const executeWorkflow = async () => { if (!selectedWorkflow || !inputMessage.trim()) { return; @@ -183,9 +317,22 @@ const Executor: React.FC = ({ workflow }) => { {selectedWorkflow.filename.replace('.json', '')}{' '} 실행 로그 -
- - {ioLogs.length}개의 대화 +
+
+ + {ioLogs.length}개의 로그 +
+ {ioLogs.length > 0 && ( + + )}
diff --git a/src/app/main/components/MainPageContent.tsx b/src/app/main/components/MainPageContent.tsx index 7be3db3b..a3942394 100644 --- a/src/app/main/components/MainPageContent.tsx +++ b/src/app/main/components/MainPageContent.tsx @@ -7,6 +7,8 @@ import CompletedWorkflows from '@/app/main/components/CompletedWorkflows'; import Playground from '@/app/main/components/Playground'; import Settings from '@/app/main/components/Settings'; import ConfigViewer from '@/app/main/components/ConfigViewer'; +import ChatHistory from '@/app/chat/components/ChatHistory'; +import CurrentChatInterface from '@/app/chat/components/CurrentChatInterface'; import { getSidebarItems } from '@/app/_common/components/sidebarConfig'; import styles from '@/app/main/assets/MainPage.module.scss'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; @@ -85,6 +87,13 @@ const MainPageContent: React.FC = () => { setActiveSection(id); }; + // 채팅 선택 처리 (Main 화면에서 기존 채팅 세션 표시) + const handleChatSelect = (executionMeta: any) => { + // 선택된 채팅을 현재 채팅으로 설정 후 current-chat 모드로 전환 + console.log('Selected chat:', executionMeta); + setActiveSection('current-chat'); + }; + const sidebarItems = getSidebarItems(); // 헤더에 표시할 토글 버튼 @@ -165,6 +174,24 @@ const MainPageContent: React.FC = () => { /> ); + case 'chat-history': + return ( + + + + ); + case 'current-chat': + return ( + + + + ); default: return ( = ({ workflow }) => { const [performanceData, setPerformanceData] = useState(null); const [performanceLoading, setPerformanceLoading] = useState(false); + const [deletingPerformance, setDeletingPerformance] = useState(false); const [error, setError] = useState(null); const [noPerformanceData, setNoPerformanceData] = useState(false); // 실행 기록이 없는 경우를 위한 상태 @@ -73,7 +76,7 @@ const Monitor: React.FC = ({ workflow }) => { // 실행 기록이 없는 경우 체크 if ( data.message === - 'No performance data found for this workflow' || + 'No performance data found for this workflow' || !data.performance_stats || data.performance_stats.length === 0 ) { @@ -109,6 +112,136 @@ const Monitor: React.FC = ({ workflow }) => { return `${mb.toFixed(2)}MB`; }; + // Handle performance data deletion with Toast confirmation (exactly like Executor) + const clearPerformanceData = async () => { + if (!selectedWorkflow) { + return; + } + + const workflowName = selectedWorkflow.filename.replace('.json', ''); + + const confirmToast = toast( + (t) => ( +
+
+ 성능 데이터 삭제 +
+
+ "{workflowName}" 워크플로우의 모든 성능 데이터를 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + +
+
+ ), + { + duration: Infinity, + style: { + maxWidth: '420px', + padding: '20px', + backgroundColor: '#f9fafb', + border: '2px solid #374151', + borderRadius: '12px', + boxShadow: + '0 8px 25px rgba(0, 0, 0, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)', + color: '#374151', + fontFamily: 'system-ui, -apple-system, sans-serif', + }, + }, + ); + }; + return ( <>
@@ -149,15 +282,28 @@ const Monitor: React.FC = ({ workflow }) => {

{performanceData.workflow_name} 성능 모니터링

- +
+ + {performanceData.performance_stats && performanceData.performance_stats.length > 0 && ( + + )} +
{/* 전체 요약 */} @@ -182,7 +328,7 @@ const Monitor: React.FC = ({ workflow }) => { {formatTime( performanceData.summary ?.avg_total_processing_time_ms || - 0, + 0, )} @@ -307,55 +453,55 @@ const Monitor: React.FC = ({ workflow }) => { {node.avg_gpu_usage_percent !== null && ( - <> -
- - GPU 사용률 - - - {node.avg_gpu_usage_percent.toFixed( - 2, - ) || 0} - % - -
-
- +
- GPU 메모리 - - + GPU 사용률 + + + {node.avg_gpu_usage_percent.toFixed( + 2, + ) || 0} + % + +
+
- {formatMemory( - node.avg_gpu_memory_mb || + + GPU 메모리 + + + {formatMemory( + node.avg_gpu_memory_mb || 0, - )} - -
- - )} + )} +
+
+ + )} ), diff --git a/src/app/main/components/Sidebar.tsx b/src/app/main/components/Sidebar.tsx index 43fb0cbd..ef173846 100644 --- a/src/app/main/components/Sidebar.tsx +++ b/src/app/main/components/Sidebar.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import { RiChatSmileAiLine } from "react-icons/ri"; +import { FiClock, FiMessageCircle } from "react-icons/fi"; import { SidebarProps } from '@/app/main/components/types'; import styles from '@/app/main/assets/MainPage.module.scss'; @@ -36,6 +37,17 @@ const Sidebar: React.FC = ({ }; const handleNewChatClick = () => { + onItemClick('new-chat'); + router.push('/chat'); + }; + + const handleChatHistoryClick = () => { + onItemClick('chat-history'); + router.push('/chat'); + }; + + const handleCurrentChatClick = () => { + onItemClick('current-chat'); router.push('/chat'); }; @@ -74,6 +86,32 @@ const Sidebar: React.FC = ({ + + + + )}