diff --git a/src/renderer/src/components/workspace/ai-chat-panel/ai-chat-panel.tsx b/src/renderer/src/components/workspace/ai-chat-panel/ai-chat-panel.tsx index dced4c8..dc75639 100644 --- a/src/renderer/src/components/workspace/ai-chat-panel/ai-chat-panel.tsx +++ b/src/renderer/src/components/workspace/ai-chat-panel/ai-chat-panel.tsx @@ -10,6 +10,10 @@ import { ChatTab } from './ai-chat.types' import { toast } from 'sonner' import { v4 as uuidv4 } from 'uuid' +interface AiChatPanelProps { + onExecuteQuery: (sql: string, chatMessageId: string) => void +} + const initialSuggestions = [ '가장 많이 팔린 상품 5개 보여줘', '지난달 매출 총액은 얼마야?', @@ -22,7 +26,7 @@ const systemMessage: Message = { content: '안녕하세요!\n자연어로 데이터베이스에 질문하시면 SQL 쿼리를 자동으로 생성해드립니다.' } -export default function AiChatPanel(): React.JSX.Element { +export default function AiChatPanel({ onExecuteQuery }: AiChatPanelProps): React.JSX.Element { const [searchTerm, setSearchTerm] = useState('') const [activeTabId, setActiveTabId] = useState(null) const [activeTabName, setActiveTabName] = useState('AI 채팅') @@ -210,7 +214,7 @@ export default function AiChatPanel(): React.JSX.Element {
{messages.map((m, index) => (
- + {m.role === 'system' && index === 0 && (
{initialSuggestions.map((suggestion, i) => ( diff --git a/src/renderer/src/components/workspace/ai-chat-panel/chat-input.tsx b/src/renderer/src/components/workspace/ai-chat-panel/chat-input.tsx index 2cddefb..661cf96 100644 --- a/src/renderer/src/components/workspace/ai-chat-panel/chat-input.tsx +++ b/src/renderer/src/components/workspace/ai-chat-panel/chat-input.tsx @@ -50,7 +50,7 @@ const ChatInput = forwardRef(function ChatI
- ChatGPT 4o + gpt-4o-mini
diff --git a/src/renderer/src/components/workspace/ai-chat-panel/chat-message.tsx b/src/renderer/src/components/workspace/ai-chat-panel/chat-message.tsx index b723787..c36e95d 100644 --- a/src/renderer/src/components/workspace/ai-chat-panel/chat-message.tsx +++ b/src/renderer/src/components/workspace/ai-chat-panel/chat-message.tsx @@ -1,10 +1,12 @@ import { Copy, Play, Save } from 'lucide-react' import { cn } from '@/lib/utils' import { Message } from '@ai-sdk/react' +import { toast } from 'sonner' interface ChatMessageProps { message: Message highlightTerm?: string + onExecuteQuery: (sql: string, chatMessageId: string) => void } /** @@ -77,9 +79,10 @@ const HighlightedText = ({ */ export default function ChatMessage({ message, - highlightTerm = '' + highlightTerm = '', + onExecuteQuery }: ChatMessageProps): React.JSX.Element { - const { role, content } = message + const { id, role, content } = message const isUser = role === 'user' const isSystem = role === 'system' @@ -91,6 +94,29 @@ export default function ChatMessage({ const sql = sqlMatch ? sqlMatch[1].trim() : null + const handleCopy = (): void => { + if (!sql) return + navigator.clipboard + .writeText(sql) + .then(() => { + toast.success('SQL이 클립보드에 복사되었습니다.') + }) + .catch((err) => { + toast.error('복사에 실패했습니다.') + console.error('Failed to copy SQL: ', err) + }) + } + + const handleSave = (): void => { + if (!sql) return + window.api.send('save-sql', sql) + } + + const handleExecute = (): void => { + if (!sql) return + onExecuteQuery(sql, id) + } + if (isSystem) { return (
@@ -128,19 +154,28 @@ export default function ChatMessage({
-
+
실행
-
+
복사
-
+
저장 diff --git a/src/renderer/src/components/workspace/query-panel/index.ts b/src/renderer/src/components/workspace/query-panel/index.ts index ab1c9ea..2ff7d7b 100644 --- a/src/renderer/src/components/workspace/query-panel/index.ts +++ b/src/renderer/src/components/workspace/query-panel/index.ts @@ -1,3 +1,3 @@ -export { default as QueryPanel } from './query-panel' +export * from './query-panel' export { default as QueryEditor } from './query-editor' export { default as QueryResults } from './query-results' diff --git a/src/renderer/src/components/workspace/query-panel/query-panel.tsx b/src/renderer/src/components/workspace/query-panel/query-panel.tsx index dd22612..723eef0 100644 --- a/src/renderer/src/components/workspace/query-panel/query-panel.tsx +++ b/src/renderer/src/components/workspace/query-panel/query-panel.tsx @@ -1,96 +1,54 @@ -import React, { useState } from 'react' +import React from 'react' import { Code2, ChartColumn, Download, Play } from 'lucide-react' import { cn } from '@/lib/utils' import QueryEditor from './query-editor' import QueryResults, { type QueryResultData } from './query-results' import { ConnectionProfile } from '../../../types/database' import ConnectionSelector from './connection-selector' -import { api } from '@renderer/utils/api' import { toast } from 'sonner' -type ActiveTab = 'editor' | 'results' +export type { QueryResultData } +export type ActiveTab = 'editor' | 'results' interface QueryPanelProps { + // Connection state connections: ConnectionProfile[] activeConnection: ConnectionProfile | null setActiveConnection: (connection: ConnectionProfile | null) => void -} - -// API가 반환하는 실제 데이터 구조에 대한 타입 -interface QueryApiResponse { - data: { - columns: string[] - data: Record[] // 객체의 배열 - } + // Query state + query: string + setQuery: (query: string) => void + result: QueryResultData | null + isLoading: boolean + error: string | null + setError: (error: string | null) => void + // Tab state + activeTab: ActiveTab + setActiveTab: (tab: ActiveTab) => void + // Execution handler + onExecuteQuery: () => Promise } /** * @author nahyeongjin1 - * @summary 쿼리 편집기 및 결과 탭 패널 + * @summary 쿼리 편집기 및 결과 탭 패널 (상태를 props로 관리) * @returns JSX.Element */ -export default function QueryPanel({ +export function QueryPanel({ connections, activeConnection, - setActiveConnection + setActiveConnection, + query, + setQuery, + result, + isLoading, + error, + setError, + activeTab, + setActiveTab, + onExecuteQuery }: QueryPanelProps): React.JSX.Element { - const [activeTab, setActiveTab] = useState('editor') - const [query, setQuery] = useState( - 'SELECT p.ProductName, SUM(sod.sales_quantity) as total_quantity_sold, SUM(sod.sales_quantity * sod.UnitPrice) as total_revenue FROM Products p JOIN SalesOrderDetails sod ON p.ProductID = sod.ProductID GROUP BY p.ProductID, p.ProductName ORDER BY total_revenue DESC LIMIT 5;' - ) - const [result, setResult] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - - const handleExecuteQuery = async (): Promise => { - if (!activeConnection) { - toast.error('쿼리를 실행할 데이터베이스를 선택해주세요.') - return - } - - setIsLoading(true) - setError(null) - - const requestBody = { - user_db_id: activeConnection.id, - database: activeConnection.name, - query_text: query - // chat_message_id는 현재 컨텍스트에 없으므로 생략하거나 임시 값을 사용합니다. - // chat_message_id: 'temp-id' - } - - try { - console.log('[QueryPanel] 쿼리 실행 요청:', requestBody) - const response = (await api.post('/api/query/execute/test', requestBody)) as QueryApiResponse - console.log('[QueryPanel] 쿼리 실행 응답:', response) - - const { columns, data: rowsAsObjects } = response.data - - // API 응답(객체의 배열)을 컴포넌트가 사용할 형태(값 배열의 배열)로 변환합니다. - const rowsAsArrays = rowsAsObjects.map((rowObject) => - columns.map((columnName) => rowObject[columnName]) - ) - - const queryResult: QueryResultData = { - columns: columns, - rows: rowsAsArrays - } - - setResult(queryResult) - setActiveTab('results') - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.' - console.error('[QueryPanel] 쿼리 실행 오류:', errorMessage) - setError(errorMessage) - setResult(null) - setActiveTab('results') - } finally { - setIsLoading(false) - } - } - const handleTabChange = (tabName: ActiveTab): void => { - // '실행 결과' 탭에서 '쿼리 편집기' 탭으로 돌아올 때 에러 상태를 초기화합니다. if (tabName === 'editor') { setError(null) } @@ -117,7 +75,6 @@ export default function QueryPanel({ row .map((cell) => { const cellStr = String(cell ?? '') - // 셀에 쉼표나 큰따옴표가 포함된 경우 큰따옴표로 감싸고, 내부 큰따옴표는 두 번 씁니다. if (cellStr.includes(',') || cellStr.includes('"')) { return `"${cellStr.replace(/"/g, '""')}"` } @@ -166,7 +123,7 @@ export default function QueryPanel({
{activeTab == 'editor' ? (