2025-05-06 06:45:49 +00:00
|
|
|
|
import React, { useState, useRef, useEffect, SyntheticEvent } from 'react'
|
|
|
|
|
|
import { Send, Bot } from 'lucide-react'
|
|
|
|
|
|
import { useStoreActions, useStoreState } from '@/store'
|
|
|
|
|
|
import { Avatar, Dropdown } from '@/components/ui'
|
|
|
|
|
|
import { AiDto, getAi } from '@/proxy/ai'
|
|
|
|
|
|
import LoadAiPostsFromLocalStorage from './LoadAiPostsFromLocalStorage'
|
|
|
|
|
|
|
|
|
|
|
|
// Types
|
|
|
|
|
|
type ChatType = 'chat' | 'query' | 'analyze'
|
|
|
|
|
|
|
|
|
|
|
|
interface BaseContent {
|
|
|
|
|
|
type: ChatType
|
|
|
|
|
|
question: string
|
|
|
|
|
|
sql: string | null
|
|
|
|
|
|
answer: string | any[]
|
|
|
|
|
|
chart?: string
|
|
|
|
|
|
error?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type MessageContent = string | BaseContent
|
|
|
|
|
|
|
|
|
|
|
|
interface Message {
|
|
|
|
|
|
role: 'user' | 'assistant'
|
|
|
|
|
|
content: MessageContent
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isContentObject = (content: MessageContent): content is BaseContent =>
|
|
|
|
|
|
typeof content !== 'string' && 'type' in content
|
|
|
|
|
|
|
|
|
|
|
|
// Main Component
|
|
|
|
|
|
const Assistant = () => {
|
|
|
|
|
|
// Hooks
|
|
|
|
|
|
const { addAiPost } = useStoreActions((actions) => actions.base.messages)
|
|
|
|
|
|
const [messages, setMessages] = useState<Message[]>([])
|
|
|
|
|
|
const [input, setInput] = useState('')
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [bot, setBot] = useState<{ key: string; name: string }[]>([])
|
|
|
|
|
|
const [selectedBot, setSelectedBot] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
const { id, avatar } = useStoreState((state) => state.auth.user)
|
|
|
|
|
|
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
|
|
|
|
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
const { VITE_AI_URL } = import.meta.env
|
|
|
|
|
|
|
|
|
|
|
|
const aiPosts = useStoreState((state) => state.base.messages.aiPosts)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (messages.length === 0 && aiPosts.length > 0) {
|
|
|
|
|
|
setMessages(aiPosts) // artık doğrudan Message[]
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [aiPosts])
|
|
|
|
|
|
|
|
|
|
|
|
// Botları çek
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const fetchBots = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await getAi()
|
|
|
|
|
|
const items =
|
|
|
|
|
|
result?.data?.items?.map((bot: AiDto) => ({
|
|
|
|
|
|
key: bot.id!,
|
|
|
|
|
|
name: bot.botName,
|
|
|
|
|
|
})) ?? []
|
|
|
|
|
|
|
|
|
|
|
|
setBot(items)
|
|
|
|
|
|
if (items.length > 0) setSelectedBot(items[0].key)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Bot listesi alınırken hata oluştu:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fetchBots()
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll to bottom
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
|
|
|
|
}, [messages])
|
|
|
|
|
|
|
|
|
|
|
|
// Input focus after loading
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!loading) inputRef.current?.focus()
|
|
|
|
|
|
}, [loading])
|
|
|
|
|
|
|
|
|
|
|
|
// Bot seçim işlemi
|
|
|
|
|
|
const onBotItemClick = (eventKey: string) => setSelectedBot(eventKey)
|
|
|
|
|
|
|
|
|
|
|
|
// Gönderme işlemi
|
|
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
if (!input.trim()) return
|
|
|
|
|
|
|
|
|
|
|
|
const userMessage = input.trim()
|
|
|
|
|
|
setInput('')
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
|
|
|
|
|
|
// 1️⃣ Soruyu store'a ekle
|
|
|
|
|
|
addAiPost({ role: 'user', content: userMessage })
|
|
|
|
|
|
|
|
|
|
|
|
setMessages((prev) => [...prev, { role: 'user', content: userMessage }])
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(VITE_AI_URL + selectedBot, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
biletId: crypto.randomUUID(),
|
|
|
|
|
|
question: userMessage,
|
|
|
|
|
|
sessionId: id,
|
|
|
|
|
|
}),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
const raw = Array.isArray(data) ? data[0] : data
|
|
|
|
|
|
|
|
|
|
|
|
const mapped: BaseContent = {
|
|
|
|
|
|
...raw,
|
|
|
|
|
|
result: raw.result || raw.answer || raw.error || 'Sonuç bulunamadı.',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2️⃣ Cevabı store'a ekle
|
|
|
|
|
|
const formattedAnswer =
|
|
|
|
|
|
typeof mapped.answer === 'string' ? mapped.answer : JSON.stringify(mapped.answer)
|
|
|
|
|
|
|
|
|
|
|
|
addAiPost({ role: 'assistant', content: mapped }) // mapped bir BaseContent
|
|
|
|
|
|
|
|
|
|
|
|
setMessages((prev) => [...prev, { role: 'assistant', content: mapped }])
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
const errorMessage = 'Üzgünüm, bir hata oluştu. Lütfen tekrar deneyin.'
|
|
|
|
|
|
addAiPost({ role: 'assistant', content: errorMessage })
|
|
|
|
|
|
setMessages((prev) => [
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
{
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: errorMessage,
|
|
|
|
|
|
},
|
|
|
|
|
|
])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Mesaj içeriği render
|
|
|
|
|
|
const renderMessageContent = (message: Message) => {
|
|
|
|
|
|
if (message.role === 'assistant' && isContentObject(message.content)) {
|
|
|
|
|
|
const { type, sql, answer, chart, error } = message.content as BaseContent & { error?: string }
|
|
|
|
|
|
|
|
|
|
|
|
const typeStyles: Record<string, string> = {
|
|
|
|
|
|
chat: 'bg-blue-100 text-blue-700',
|
|
|
|
|
|
query: 'bg-green-100 text-green-700',
|
|
|
|
|
|
analyze: 'bg-yellow-100 text-yellow-800',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const typeLabels: Record<string, string> = {
|
|
|
|
|
|
chat: '🗨️ Sohbet',
|
|
|
|
|
|
query: '📊 Veritabanı Sorgusu',
|
|
|
|
|
|
analyze: '📈 Analiz & Değerlendirme',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const cleanedSql = (() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const rawSql = decodeURIComponent(sql || '')
|
|
|
|
|
|
return rawSql.replace(/^```sql\n?/, '').replace(/```$/, '')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return sql
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-2 text-sm">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`inline-block px-3 py-1 text-xs font-medium rounded-full mb-2 ${typeStyles[type] || 'bg-gray-100 text-gray-700'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{typeLabels[type] || type}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{sql && (
|
|
|
|
|
|
<div className="bg-gray-100 p-3 rounded border text-xs font-mono whitespace-pre-wrap">
|
|
|
|
|
|
<pre>
|
|
|
|
|
|
<code>{cleanedSql}</code>
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
|
<div className="text-red-700 bg-red-100 border border-red-300 rounded p-2">
|
|
|
|
|
|
⚠️ <strong>Hata:</strong> {error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : typeof answer === 'string' ? (
|
|
|
|
|
|
<div className="whitespace-pre-wrap">{answer}</div>
|
|
|
|
|
|
) : Array.isArray(answer) ? (
|
|
|
|
|
|
answer.length === 0 ? (
|
|
|
|
|
|
<div className="text-gray-500 italic">Sonuç bulunamadı.</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="overflow-x-auto max-h-[400px] overflow-y-auto border rounded">
|
|
|
|
|
|
<table className="table-auto w-full text-sm">
|
|
|
|
|
|
<thead className="bg-gray-100 sticky top-0">
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
{Object.keys(answer[0]).map((col) => (
|
|
|
|
|
|
<th key={col} className="border px-2 py-1 text-left">
|
|
|
|
|
|
{col}
|
|
|
|
|
|
</th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{answer.map((row, rowIndex) => (
|
|
|
|
|
|
<tr key={rowIndex} className="hover:bg-gray-50">
|
|
|
|
|
|
{Object.keys(row).map((col, colIndex) => {
|
|
|
|
|
|
const val = row[col]
|
|
|
|
|
|
const display =
|
|
|
|
|
|
val === null || val === undefined
|
|
|
|
|
|
? '—'
|
|
|
|
|
|
: typeof val === 'boolean'
|
|
|
|
|
|
? val ? 'Evet' : 'Hayır'
|
|
|
|
|
|
: typeof val === 'string' && val.endsWith('T00:00:00.000Z')
|
|
|
|
|
|
? new Date(val).toLocaleDateString('tr-TR')
|
|
|
|
|
|
: String(val)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td key={colIndex} className="border px-2 py-1">
|
|
|
|
|
|
{display}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
) : typeof answer === 'object' && answer !== null && (answer as any).message ? (
|
|
|
|
|
|
<div className="whitespace-pre-wrap">{String((answer as any).message)}</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<pre className="text-xs bg-gray-100 p-2 rounded border">
|
|
|
|
|
|
{JSON.stringify(answer, null, 2)}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{chart && (
|
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={`data:image/png;base64,${chart}`}
|
|
|
|
|
|
alt="Grafik"
|
|
|
|
|
|
className="max-w-full rounded border"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return <div className="whitespace-pre-wrap text-sm">{String(message.content)}</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Render
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<LoadAiPostsFromLocalStorage />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="h-[calc(100vh-140px)] flex flex-col">
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
|
|
|
|
{messages.length === 0 && (
|
|
|
|
|
|
<div className="text-center text-gray-500 mt-8">
|
|
|
|
|
|
<Bot className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
|
|
|
|
|
<p className="mt-2">Hoş Geldiniz!</p>
|
2025-05-06 11:03:45 +00:00
|
|
|
|
<p className="text-lg font-medium">Kurs AI</p>
|
2025-05-06 06:45:49 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{messages.map((msg, idx) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={idx}
|
|
|
|
|
|
className={`flex items-start gap-2.5 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center ${msg.role === 'user' ? 'bg-blue-500' : 'bg-gray-600'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{msg.role === 'user' ? (
|
|
|
|
|
|
<Avatar size={32} shape="circle" src={avatar} alt="avatar" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Bot className="w-5 h-5 text-white" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`max-w-[80%] rounded-lg p-2 ${msg.role === 'user' ? 'bg-blue-500 text-white' : 'bg-white text-gray-800'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{renderMessageContent(msg)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{loading && (
|
|
|
|
|
|
<div className="flex items-center justify-center gap-2 text-gray-500">
|
|
|
|
|
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-500 border-t-transparent" />
|
|
|
|
|
|
Yanıt bekleniyor...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div ref={bottomRef}></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleSubmit} className="relative w-full mx-auto px-4">
|
|
|
|
|
|
<div className="relative w-full bg-[#2a2a2a] text-white rounded-3xl px-4 pt-4 pb-10 shadow-md">
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
value={input}
|
|
|
|
|
|
onChange={(e) => setInput(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
handleSubmit(e)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder="Ask anything"
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
className="w-full resize-none bg-transparent border-none outline-none text-base placeholder-gray-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="absolute bottom-1 left-2 flex items-center gap-2 text-sm">
|
|
|
|
|
|
<Dropdown
|
|
|
|
|
|
placement="top-start"
|
|
|
|
|
|
title={bot.find((item) => item.key === selectedBot)?.name || 'Bot Seç'}
|
|
|
|
|
|
>
|
|
|
|
|
|
{bot.map((item) => (
|
|
|
|
|
|
<Dropdown.Item key={item.key} eventKey={item.key} onSelect={onBotItemClick}>
|
|
|
|
|
|
{item.name}
|
|
|
|
|
|
</Dropdown.Item>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Dropdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="absolute bottom-2 right-3 flex items-center gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="Mic"
|
|
|
|
|
|
className="rounded-full h-8 w-8 flex items-center justify-center text-gray-400 hover:text-white"
|
|
|
|
|
|
>
|
|
|
|
|
|
🎤
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={loading}
|
|
|
|
|
|
className="rounded-full h-8 w-8 flex items-center justify-center bg-gray-500 hover:bg-blue-600 text-white"
|
|
|
|
|
|
>
|
|
|
|
|
|
⬆
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default Assistant
|