Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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개 보여줘',
'지난달 매출 총액은 얼마야?',
Expand All @@ -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<string | null>(null)
const [activeTabName, setActiveTabName] = useState<string>('AI 채팅')
Expand Down Expand Up @@ -210,7 +214,7 @@ export default function AiChatPanel(): React.JSX.Element {
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-6">
{messages.map((m, index) => (
<div key={m.id}>
<ChatMessage message={m} highlightTerm={searchTerm} />
<ChatMessage message={m} highlightTerm={searchTerm} onExecuteQuery={onExecuteQuery} />
{m.role === 'system' && index === 0 && (
<div className="self-stretch inline-flex justify-start items-center gap-3 flex-wrap mt-3">
{initialSuggestions.map((suggestion, i) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(function ChatI
<div className="flex justify-start items-center gap-[5px]">
<BotMessageSquare className="size-3 stroke-[#E4E4E4]" />
<div className="justify-start text-neutral-200 text-xs font-semibold font-['Pretendard'] leading-none">
ChatGPT 4o
gpt-4o-mini
</div>
<ChevronRight className="size-3 stroke-[#E4E4E4]" />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}

/**
Expand Down Expand Up @@ -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'
Expand All @@ -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 (
<div className="self-stretch flex flex-col justify-start items-start gap-3">
Expand Down Expand Up @@ -128,19 +154,28 @@ export default function ChatMessage({
<HighlightedText text={sql} highlight={highlightTerm} />
</div>
<div className="inline-flex justify-start items-start gap-2.5">
<div className="px-3 py-1.5 bg-neutral-800 rounded-md outline-1 outline-offset-[-1px] outline-neutral-700 flex justify-center items-center gap-2">
<div
onClick={handleExecute}
className="px-3 py-1.5 bg-neutral-800 rounded-md outline-1 outline-offset-[-1px] outline-neutral-700 flex justify-center items-center gap-2 cursor-pointer"
>
<Play className="size-4 stroke-[#E4E4E4]" />
<div className="py-0.75 justify-start text-neutral-200 text-xs font-semibold font-['Pretendard'] leading-none">
실행
</div>
</div>
<div className="px-3 py-1.5 bg-neutral-800 rounded-md outline-1 outline-offset-[-1px] outline-neutral-700 flex justify-center items-center gap-2">
<div
onClick={handleCopy}
className="px-3 py-1.5 bg-neutral-800 rounded-md outline-1 outline-offset-[-1px] outline-neutral-700 flex justify-center items-center gap-2 cursor-pointer"
>
<Copy className="size-4 stroke-[#E4E4E4]" />
<div className="py-0.75 justify-start text-neutral-200 text-xs font-semibold font-['Pretendard'] leading-none">
복사
</div>
</div>
<div className="px-3 py-1.5 bg-neutral-800 rounded-md outline-1 outline-offset-[-1px] outline-neutral-700 flex justify-center items-center gap-2">
<div
onClick={handleSave}
className="px-3 py-1.5 bg-neutral-800 rounded-md outline-1 outline-offset-[-1px] outline-neutral-700 flex justify-center items-center gap-2 cursor-pointer"
>
<Save className="size-4 stroke-[#E4E4E4]" />
<div className="py-0.75 justify-start text-neutral-200 text-xs font-semibold font-['Pretendard'] leading-none">
저장
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/workspace/query-panel/index.ts
Original file line number Diff line number Diff line change
@@ -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'
101 changes: 29 additions & 72 deletions src/renderer/src/components/workspace/query-panel/query-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | number | null>[] // 객체의 배열
}
// 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<void>
}

/**
* @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<ActiveTab>('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<QueryResultData | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)

const handleExecuteQuery = async (): Promise<void> => {
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)
}
Expand All @@ -117,7 +75,6 @@ export default function QueryPanel({
row
.map((cell) => {
const cellStr = String(cell ?? '')
// 셀에 쉼표나 큰따옴표가 포함된 경우 큰따옴표로 감싸고, 내부 큰따옴표는 두 번 씁니다.
if (cellStr.includes(',') || cellStr.includes('"')) {
return `"${cellStr.replace(/"/g, '""')}"`
}
Expand Down Expand Up @@ -166,7 +123,7 @@ export default function QueryPanel({
<div className="flex items-center gap-2">
{activeTab == 'editor' ? (
<button
onClick={handleExecuteQuery}
onClick={onExecuteQuery}
disabled={isLoading || !activeConnection}
className={cn(
'flex items-center gap-2 bg-gradient-genie-gray rounded-lg px-3 py-1.5 outline-1 outline-white/20 outline-offset-[-1px]',
Expand Down
Loading