erp-platform/ui/src/views/ai/Assistant.tsx
2025-08-16 22:47:24 +03:00

373 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect, SyntheticEvent } from 'react'
import { FaRobot } from 'react-icons/fa';
import { useStoreActions, useStoreState } from '@/store'
import { Avatar, Dropdown } from '@/components/ui'
import LoadAiPostsFromLocalStorage from './LoadAiPostsFromLocalStorage'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { getAi } from '@/services/ai.service'
import { AiDto } from '@/proxy/ai/models'
import { Container } from '@/components/shared'
import { Helmet } from 'react-helmet'
// 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 { translate } = useLocalization()
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: '🗨️ ' + translate('::AI.SohbetAnswer'),
query: '📊 ' + translate('::AI.DatabaseAnswer'),
analyze: '📈 ' + translate('::AI.AnalizAnswer'),
}
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 (
<Container>
<Helmet
titleTemplate="%s | Kurs Platform"
title={translate('::' + 'Abp.Identity.Ai')}
defaultTitle="Kurs Platform"
></Helmet>
<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">
<FaRobot className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="mt-2">{translate('::AI.Welcome')}</p>
<p className="text-lg font-medium">{translate('::AI.Name')}</p>
</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" />
) : (
<FaRobot 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" />
{translate('::AI.WaitAnswer')}
</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={translate('::AI.Asking')}
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 ||
translate('::AI.SelectModel')
}
>
{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>
</Container>
)
}
export default Assistant