From f1fe72ad4072a90d4ff1e19ab5aa0fe2778dd105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 21 Aug 2025 10:49:57 +0900 Subject: [PATCH 1/6] fix: prevent init errors on Linux environment --- src/main/index.ts | 5 ++++- src/main/ipc/handlers.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index 4d48e1d..aa0d6a0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -119,6 +119,9 @@ async function startBackendServices(): Promise { } else if (platform === 'darwin') { apiPath = path.join(basePath, 'mac', 'qgenie-api') aiPath = path.join(basePath, 'mac', 'qgenie-ai') + } else if (platform === 'linux') { + apiPath = path.join(basePath, 'linux', 'qgenie-api') + aiPath = path.join(basePath, 'linux', 'qgenie-ai') } else { throw new Error(`Unsupported platform: ${platform}`) } @@ -126,7 +129,7 @@ async function startBackendServices(): Promise { if (!fs.existsSync(apiPath)) throw new Error(`API executable not found: ${apiPath}`) if (!fs.existsSync(aiPath)) throw new Error(`AI executable not found: ${aiPath}`) - if (platform === 'darwin') { + if (platform === 'darwin' || platform === 'linux') { fs.chmodSync(apiPath, 0o755) fs.chmodSync(aiPath, 0o755) } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index edb54c4..66606f7 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -58,6 +58,8 @@ export function registerIpcHandlers(mainWindow?: BrowserWindow): void { requestBody ) + console.log('Backend response data:', JSON.stringify(response.data, null, 2)) + if (response.data && response.data.data) { const aiMessage = response.data.data.message as string From 4a222e9ae56f915dcbdcbfe623d4490ac8980fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 21 Aug 2025 12:50:17 +0900 Subject: [PATCH 2/6] style(chat): prevent empty bubble before AI response --- .../src/components/workspace/ai-chat-panel/chat-message.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b477382..49ea74d 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 @@ -108,7 +108,7 @@ export default function ChatMessage({ isUser ? 'items-end' : 'items-start' )} > - {(isAi || isUser) && ( + {(isAi || isUser) && mainContent && (
Date: Thu, 21 Aug 2025 14:05:33 +0900 Subject: [PATCH 3/6] feat(query): connect editor and result with APIs --- .../db-schema-panel/db-schema-panel.tsx | 47 +++---- .../query-panel/ConnectionSelector.tsx | 46 +++++++ .../workspace/query-panel/query-panel.tsx | 103 ++++++++++---- .../workspace/query-panel/query-results.tsx | 128 +++++++++--------- .../src/components/workspace/workspace.tsx | 57 +++++++- src/renderer/src/types/database.ts | 11 ++ 6 files changed, 272 insertions(+), 120 deletions(-) create mode 100644 src/renderer/src/components/workspace/query-panel/ConnectionSelector.tsx create mode 100644 src/renderer/src/types/database.ts diff --git a/src/renderer/src/components/workspace/db-schema-panel/db-schema-panel.tsx b/src/renderer/src/components/workspace/db-schema-panel/db-schema-panel.tsx index c7083c6..977f141 100644 --- a/src/renderer/src/components/workspace/db-schema-panel/db-schema-panel.tsx +++ b/src/renderer/src/components/workspace/db-schema-panel/db-schema-panel.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { api } from '@renderer/utils/api' import { SchemaNode, SchemaNodeType } from './db-schema.types' import SchemaTreeItem from './schema-tree-item' +import { ConnectionProfile } from '../../../types/database' // API 응답 타입을 위한 인터페이스 정의 interface ApiResponse { @@ -11,12 +12,6 @@ interface ApiResponse { data: T } -interface DbProfile { - id: string - view_name?: string - name?: string -} - interface ColumnInfo { name: string } @@ -47,28 +42,34 @@ const initializeExpandedState = (nodes: SchemaNode[]): Record = return state } +interface DbSchemaPanelProps { + profiles: ConnectionProfile[] + isLoading: boolean +} + /** * @author nahyeongjin1 * @summary DB 스키마 정보를 보여주는 패널 * @returns JSX.Element */ -export default function DbSchemaPanel(): React.JSX.Element { +export default function DbSchemaPanel({ + profiles, + isLoading: isLoadingProfiles +}: DbSchemaPanelProps): React.JSX.Element { const [schemaData, setSchemaData] = useState([]) - const [isLoading, setIsLoading] = useState(true) + const [isSchemaLoading, setIsSchemaLoading] = useState(true) const [expandedNodes, setExpandedNodes] = useState>({}) useEffect(() => { + // profiles가 비어있거나 로딩 중이면 아무것도 하지 않음 + if (isLoadingProfiles || profiles.length === 0) { + if (!isLoadingProfiles) setIsSchemaLoading(false) + return + } + const fetchSchemaData = async (): Promise => { try { - const profilesRes = (await api.get('/api/user/db/find/all')) as unknown as ApiResponse< - DbProfile[] - > - const profiles = profilesRes.data - - if (!profiles || !Array.isArray(profiles)) { - throw new Error('Invalid profile data received') - } - + console.log('[DbSchemaPanel] 받은 profiles로 스키마 정보 조회를 시작합니다:', profiles) const allSchemasPromises = profiles.map((profile) => api.get(`/api/user/db/find/hierarchical-schema/${profile.id}`) ) @@ -87,7 +88,7 @@ export default function DbSchemaPanel(): React.JSX.Element { response.data.length === 0 ) { console.warn( - `Could not fetch schema for ${profile.view_name}: ${response.message || 'Empty data'}` + `[DbSchemaPanel] 스키마 조회 실패 ${profile.view_name}: ${response.message || 'Empty data'}` ) return null } @@ -115,19 +116,19 @@ export default function DbSchemaPanel(): React.JSX.Element { } }) .filter(Boolean) as SchemaNode[] - + console.log('[DbSchemaPanel] 스키마 정보 변환 완료:', transformedData) setSchemaData(transformedData) setExpandedNodes(initializeExpandedState(transformedData)) } catch (error) { toast.error('데이터베이스 스키마 정보를 불러오는 데 실패했습니다.') - console.error(error) + console.error('[DbSchemaPanel] 스키마 정보 조회 실패:', error) } finally { - setIsLoading(false) + setIsSchemaLoading(false) } } fetchSchemaData() - }, []) + }, [profiles, isLoadingProfiles]) const handleToggle = (nodeId: string): void => { setExpandedNodes((prev) => ({ @@ -136,7 +137,7 @@ export default function DbSchemaPanel(): React.JSX.Element { })) } - if (isLoading) { + if (isLoadingProfiles || isSchemaLoading) { return (

로딩 중...

diff --git a/src/renderer/src/components/workspace/query-panel/ConnectionSelector.tsx b/src/renderer/src/components/workspace/query-panel/ConnectionSelector.tsx new file mode 100644 index 0000000..8c52add --- /dev/null +++ b/src/renderer/src/components/workspace/query-panel/ConnectionSelector.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { ConnectionProfile } from '../../../types/database' +import { ChevronDown } from 'lucide-react' + +interface ConnectionSelectorProps { + connections: ConnectionProfile[] + activeConnection: ConnectionProfile | null + setActiveConnection: (connection: ConnectionProfile | null) => void +} + +const ConnectionSelector: React.FC = ({ + connections, + activeConnection, + setActiveConnection +}) => { + const handleSelectionChange = (event: React.ChangeEvent): void => { + const selectedId = event.target.value + const selectedConnection = connections.find((c) => c.id === selectedId) || null + console.log('[ConnectionSelector] DB 연결 변경:', selectedConnection) + setActiveConnection(selectedConnection) + } + + return ( +
+ + +
+ ) +} + +export default ConnectionSelector 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 c7a5c40..cbcc411 100644 --- a/src/renderer/src/components/workspace/query-panel/query-panel.tsx +++ b/src/renderer/src/components/workspace/query-panel/query-panel.tsx @@ -3,30 +3,25 @@ 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 './ConnectionSelector' +import { api } from '@renderer/utils/api' +import { toast } from 'sonner' type ActiveTab = 'editor' | 'results' -// Mock API function -const executeQuery = async (query: string): Promise => { - console.log('Executing query:', query) - return new Promise((resolve, reject) => { - setTimeout(() => { - // Simulate success/error randomly - if (Math.random() > 0.3) { - resolve({ - columns: ['id', 'name', 'email', 'age'], - rows: [ - [1, 'Alice', 'alice@example.com', 30], - [2, 'Bob', 'bob@example.com', 25], - [3, 'Charlie', 'charlie@example.com', 35], - [4, 'David', 'david@example.com', 28] - ] - }) - } else { - reject(new Error("Syntax error near 'FROM'. Check your SQL syntax.")) - } - }, 1500) - }) +interface QueryPanelProps { + connections: ConnectionProfile[] + activeConnection: ConnectionProfile | null + setActiveConnection: (connection: ConnectionProfile | null) => void +} + +// API가 반환하는 실제 데이터 구조에 대한 타입 +interface QueryApiResponse { + data: { + columns: string[] + data: Record[] // 객체의 배열 + } } /** @@ -34,7 +29,11 @@ const executeQuery = async (query: string): Promise => { * @summary 쿼리 편집기 및 결과 탭 패널 * @returns JSX.Element */ -export default function QueryPanel(): React.JSX.Element { +export default function QueryPanel({ + connections, + activeConnection, + setActiveConnection +}: 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;' @@ -44,14 +43,45 @@ export default function QueryPanel(): React.JSX.Element { 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 { - const queryResult = await executeQuery(query) + 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) { - setError(err instanceof Error ? err.message : 'An unknown error occurred.') + const errorMessage = err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.' + console.error('[QueryPanel] 쿼리 실행 오류:', errorMessage) + setError(errorMessage) setResult(null) setActiveTab('results') } finally { @@ -59,6 +89,14 @@ export default function QueryPanel(): React.JSX.Element { } } + const handleTabChange = (tabName: ActiveTab): void => { + // '실행 결과' 탭에서 '쿼리 편집기' 탭으로 돌아올 때 에러 상태를 초기화합니다. + if (tabName === 'editor') { + setError(null) + } + setActiveTab(tabName) + } + const TabButton = ({ tabName, Icon, @@ -69,7 +107,7 @@ export default function QueryPanel(): React.JSX.Element { label: string }): React.JSX.Element => (
setActiveTab(tabName)} + onClick={() => handleTabChange(tabName)} className={cn( 'group flex items-center gap-2 py-[16.5px] cursor-pointer border-b-3 -mb-px', activeTab === tabName @@ -83,20 +121,26 @@ export default function QueryPanel(): React.JSX.Element { ) return ( -
+
+ {/* Header */}
+ {activeTab == 'editor' ? (
-
+ {/* Content */} +
{activeTab === 'editor' && } {activeTab === 'results' && ( diff --git a/src/renderer/src/components/workspace/query-panel/query-results.tsx b/src/renderer/src/components/workspace/query-panel/query-results.tsx index b83d242..cc7b4e7 100644 --- a/src/renderer/src/components/workspace/query-panel/query-results.tsx +++ b/src/renderer/src/components/workspace/query-panel/query-results.tsx @@ -14,80 +14,80 @@ export default function QueryResults({ isLoading, error }: QueryResultsProps): React.JSX.Element { - const hasResults = result && result.columns.length > 0 + const hasResults = result && result.columns.length > 0 && result.rows - const renderContent = (): React.ReactNode => { - if (isLoading) { - return ( -
- 쿼리를 실행 중입니다... -
- ) - } - - if (error) { - return ( -
오류: {error}
- ) - } + if (isLoading) { + return ( +
+ 쿼리를 실행 중입니다... +
+ ) + } - if (hasResults) { - return ( -
- - - - {result.columns.map((column) => ( - - ))} - - - - {result.rows.length > 0 ? ( - result.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - - ))} - - )) - ) : ( - - - - )} - -
- {column} -
- {String(cell)} -
- 쿼리는 성공했지만 반환된 행이 없습니다. -
+ if (error) { + return ( +
+
+
오류: {error}
- ) - } +
+ ) + } + if (hasResults) { return ( -
- 쿼리를 실행하여 결과를 확인하세요. +
+ + + + {result.columns.map((column) => ( + + ))} + + + + {result.rows.length > 0 ? ( + result.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {column} +
+ {cell === null ? ( + NULL + ) : ( + String(cell) + )} +
+ 쿼리는 성공했지만 반환된 행이 없습니다. +
) } return ( -
-
{renderContent()}
+
+ 쿼리를 실행하여 결과를 확인하세요.
) } diff --git a/src/renderer/src/components/workspace/workspace.tsx b/src/renderer/src/components/workspace/workspace.tsx index 5cd9f71..c513b5f 100644 --- a/src/renderer/src/components/workspace/workspace.tsx +++ b/src/renderer/src/components/workspace/workspace.tsx @@ -1,20 +1,69 @@ +import { useEffect, useState } from 'react' import { DbSchemaPanel } from './db-schema-panel' import { AiChatPanel } from './ai-chat-panel' import { QueryPanel } from './query-panel' +import { api } from '@renderer/utils/api' +import { toast } from 'sonner' +import { ConnectionProfile } from '@renderer/types/database' +import { ApiResponse } from '@renderer/types' const WorkSpace = (): React.JSX.Element => { + const [connections, setConnections] = useState([]) + const [activeConnection, setActiveConnection] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const fetchConnections = async (): Promise => { + try { + console.log('[Workspace] DB 연결 목록 조회를 시작합니다.') + const res = await api.get>('/api/user/db/find/all') + console.log('[Workspace] Raw API response:', res) + + if (res && Array.isArray(res.data)) { + const fetchedConnections = res.data + console.log('[Workspace] DB 연결 목록 조회 성공:', fetchedConnections) + setConnections(fetchedConnections) + + if (fetchedConnections.length > 0) { + console.log('[Workspace] 첫 번째 연결을 활성 DB로 설정합니다:', fetchedConnections[0]) + setActiveConnection(fetchedConnections[0]) + } else { + console.log('[Workspace] 저장된 DB 연결이 없습니다.') + } + } else { + throw new Error('Invalid API response format') + } + } catch (error) { + toast.error('DB 연결 목록을 불러오는 데 실패했습니다.') + console.error('[Workspace] DB 연결 목록 조회 실패:', error) + } finally { + setIsLoading(false) + } + } + + fetchConnections() + }, []) + return (
{/* DB Schema Panel (Left) */} - + {/* Main Content (Center & Right) */} -
+
{/* AI Chat Panel (Center) */} - +
+ +
{/* Query & Results Panel (Right) */} - +
+ +
) diff --git a/src/renderer/src/types/database.ts b/src/renderer/src/types/database.ts new file mode 100644 index 0000000..869d296 --- /dev/null +++ b/src/renderer/src/types/database.ts @@ -0,0 +1,11 @@ +export interface ConnectionProfile { + id: string + type: 'mysql' | 'mariadb' | 'postgresql' | 'oracle' | 'sqlite' + host: string | null + port: number | null + name: string | null + username: string | null + view_name: string | null + created_at: string + updated_at: string +} From 6bc80c70fa001d64c933363fe09c1fcfdcf40e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 21 Aug 2025 14:09:15 +0900 Subject: [PATCH 4/6] style(ui): move ConnectionSelector dropdown above query editor --- ...onSelector.tsx => connection-selector.tsx} | 0 .../workspace/query-panel/query-panel.tsx | 20 ++++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) rename src/renderer/src/components/workspace/query-panel/{ConnectionSelector.tsx => connection-selector.tsx} (100%) diff --git a/src/renderer/src/components/workspace/query-panel/ConnectionSelector.tsx b/src/renderer/src/components/workspace/query-panel/connection-selector.tsx similarity index 100% rename from src/renderer/src/components/workspace/query-panel/ConnectionSelector.tsx rename to src/renderer/src/components/workspace/query-panel/connection-selector.tsx 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 cbcc411..3a8607c 100644 --- a/src/renderer/src/components/workspace/query-panel/query-panel.tsx +++ b/src/renderer/src/components/workspace/query-panel/query-panel.tsx @@ -4,7 +4,7 @@ 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 './ConnectionSelector' +import ConnectionSelector from './connection-selector' import { api } from '@renderer/utils/api' import { toast } from 'sonner' @@ -129,11 +129,6 @@ export default function QueryPanel({
- {activeTab == 'editor' ? (