sozsoft-platform/ui/src/views/ai/Assistant.tsx
2026-06-20 12:09:37 +03:00

735 lines
26 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 } from 'react'
import { FaComment, FaPaperPlane, FaPlus, FaRobot, FaTrash } from 'react-icons/fa'
import { useStoreState } from '@/store'
import { Avatar, Button, Drawer, Dropdown, ScrollBar } from '@/components/ui'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { aiService } from '@/services/ai.service'
import { AiDto } from '@/proxy/ai/models'
import { Container } from '@/components/shared'
import { Helmet } from 'react-helmet'
import { APP_NAME } from '@/constants/app.constant'
import dayjs from 'dayjs'
import { AI_ASSISTANT } from '@/constants/permission.constant'
// 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
createdAt?: string
}
interface Conversation {
id: string
title: string
messages: Message[]
createdAt: string
updatedAt: string
}
const CONVERSATIONS_STORAGE_KEY = 'AiConversations'
const ACTIVE_CONVERSATION_STORAGE_KEY = 'AiActiveConversationId'
const getConversationTitle = (message: string) =>
message.length > 42 ? `${message.slice(0, 42).trimEnd()}` : message
const isContentObject = (content: MessageContent): content is BaseContent =>
typeof content !== 'string' && 'type' in content
const renderInlineText = (text: string) =>
text.split(/(\*\*[^*]+\*\*|"[^"\n]+")/g).map((part, index) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={index}>{part.slice(2, -2)}</strong>
}
if (part.startsWith('"') && part.endsWith('"')) {
return (
<q
key={index}
className="rounded-md border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-medium text-blue-800 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-200"
>
{part.slice(1, -1)}
</q>
)
}
return <React.Fragment key={index}>{part}</React.Fragment>
})
const renderFormattedText = (value: string) => {
const text = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\r\n/g, '\n')
return (
<div className="space-y-2 whitespace-pre-wrap leading-6">
{text.split('\n').map((line, index) => {
if (!line.trim()) return <div key={index} className="h-2" />
const listMarker = line.match(/^[-*+]\s+/)
const isListItem = Boolean(listMarker)
const content = isListItem ? line.slice(listMarker![0].length) : line
return isListItem ? (
<div key={index} className="flex gap-2">
<span className="text-blue-500 dark:text-blue-300"></span>
<span>{renderInlineText(content)}</span>
</div>
) : (
<p key={index}>{renderInlineText(content)}</p>
)
})}
</div>
)
}
// Main Component
const Assistant = () => {
// Hooks
const [conversations, setConversations] = useState<Conversation[]>([])
const [activeConversationId, setActiveConversationId] = useState<string | null>(null)
const [isConversationsLoaded, setIsConversationsLoaded] = useState(false)
const [isConversationDrawerOpen, setIsConversationDrawerOpen] = useState(false)
const [, setMessages] = useState<Message[]>([])
const aiPosts: Message[] = []
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [bot, setBot] = useState<AiDto[]>([])
const [selectedBot, setSelectedBot] = useState<string | null>(null)
const { avatar } = useStoreState((state) => state.auth.user)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const { translate } = useLocalization()
const createConversation = (): Conversation => {
const now = new Date().toISOString()
return {
id: crypto.randomUUID(),
title: translate('::App.Platform.NewChat'),
messages: [],
createdAt: now,
updatedAt: now,
}
}
useEffect(() => {
if (messages.length === 0 && aiPosts.length > 0) {
setMessages(aiPosts) // artık doğrudan Message[]
}
}, [aiPosts])
useEffect(() => {
try {
const saved = localStorage.getItem(CONVERSATIONS_STORAGE_KEY)
const parsed = saved ? JSON.parse(saved) : []
const savedConversations = Array.isArray(parsed)
? parsed.filter(
(conversation): conversation is Conversation =>
conversation &&
typeof conversation.id === 'string' &&
Array.isArray(conversation.messages),
)
: []
if (savedConversations.length > 0) {
const savedActiveConversationId = localStorage.getItem(ACTIVE_CONVERSATION_STORAGE_KEY)
setConversations(savedConversations)
setActiveConversationId(
savedConversations.some((conversation) => conversation.id === savedActiveConversationId)
? savedActiveConversationId
: savedConversations[0].id,
)
} else {
const legacyPosts = JSON.parse(localStorage.getItem('AiPosts') || '[]')
const conversation = createConversation()
if (Array.isArray(legacyPosts) && legacyPosts.length > 0) {
conversation.messages = legacyPosts
const firstQuestion = legacyPosts.find((message) => message.role === 'user')?.content
conversation.title = firstQuestion
? getConversationTitle(String(firstQuestion))
: conversation.title
}
setConversations([conversation])
setActiveConversationId(conversation.id)
}
} catch (error) {
console.warn('AI conversations localStorage parse error', error)
const conversation = createConversation()
setConversations([conversation])
setActiveConversationId(conversation.id)
}
setIsConversationsLoaded(true)
}, [])
useEffect(() => {
if (!isConversationsLoaded) return
localStorage.setItem(CONVERSATIONS_STORAGE_KEY, JSON.stringify(conversations))
if (activeConversationId) {
localStorage.setItem(ACTIVE_CONVERSATION_STORAGE_KEY, activeConversationId)
}
}, [activeConversationId, conversations, isConversationsLoaded])
const activeConversation = conversations.find(
(conversation) => conversation.id === activeConversationId,
)
const messages = activeConversation?.messages || []
const appendMessage = (conversationId: string, message: Message) => {
setConversations((previous) => {
const updatedConversations = previous.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
title:
conversation.messages.length === 0 && message.role === 'user'
? getConversationTitle(String(message.content))
: conversation.title,
messages: [...conversation.messages, message],
updatedAt: message.createdAt || new Date().toISOString(),
}
: conversation,
)
return updatedConversations.sort(
(first, second) => Date.parse(second.updatedAt) - Date.parse(first.updatedAt),
)
})
}
const handleNewConversation = () => {
if (loading) return
const emptyConversation = conversations.find(
(conversation) => conversation.messages.length === 0,
)
if (emptyConversation) {
setActiveConversationId(emptyConversation.id)
setInput('')
setIsConversationDrawerOpen(false)
requestAnimationFrame(() => inputRef.current?.focus())
return
}
const conversation = createConversation()
setConversations((previous) => [conversation, ...previous])
setActiveConversationId(conversation.id)
setInput('')
setIsConversationDrawerOpen(false)
requestAnimationFrame(() => inputRef.current?.focus())
}
const handleSelectConversation = (conversationId: string) => {
if (loading) return
setActiveConversationId(conversationId)
setIsConversationDrawerOpen(false)
requestAnimationFrame(() => inputRef.current?.focus())
}
const handleDeleteConversation = (conversationId: string) => {
if (loading) return
const remainingConversations = conversations.filter(
(conversation) => conversation.id !== conversationId,
)
const nextConversations = remainingConversations.length
? remainingConversations
: [createConversation()]
setConversations(nextConversations)
if (conversationId === activeConversationId) {
setActiveConversationId(nextConversations[0].id)
setInput('')
}
}
// Botları çek
useEffect(() => {
const fetchBots = async () => {
try {
const result = await aiService.getList({
skipCount: 0,
maxResultCount: 1000,
sorting: 'name',
})
if (!result?.data?.items) {
console.warn('Bot listesi boş veya hatalı formatta:', result)
setBot([])
return
}
setBot(result?.data?.items)
const firstBotId = result?.data?.items?.find((item) => item.id)?.id ?? null
setSelectedBot(firstBotId)
} 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() || !activeConversation) return
const userMessage = input.trim()
const conversationId = activeConversation.id
const userMessageCreatedAt = new Date().toISOString()
setInput('')
setLoading(true)
// 1⃣ Soruyu store'a ekle
appendMessage(conversationId, {
role: 'user',
content: userMessage,
createdAt: userMessageCreatedAt,
})
try {
const selectedBotItem = bot.find((item) => item.id === selectedBot)
const targetUrl = (selectedBotItem?.apiUrl || '').trim()
if (!targetUrl) {
throw new Error('AI bot URL bulunamadı.')
}
const response = await fetch(targetUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
biletId: conversationId,
question: userMessage,
sessionId: conversationId,
}),
})
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 assistantMessageCreatedAt = new Date().toISOString()
appendMessage(conversationId, {
role: 'assistant',
content: mapped,
createdAt: assistantMessageCreatedAt,
})
} catch {
const errorMessage = 'Üzgünüm, bir hata oluştu. Lütfen tekrar deneyin.'
const assistantMessageCreatedAt = new Date().toISOString()
appendMessage(conversationId, {
role: 'assistant',
content: errorMessage,
createdAt: assistantMessageCreatedAt,
})
}
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 dark:bg-blue-900/50 dark:text-blue-200',
query: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-200',
analyze: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-200',
}
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 dark:bg-gray-700 dark:text-gray-200'}`}
>
{typeLabels[type] || type}
</div>
{sql && (
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded border border-gray-200 dark:border-gray-700 text-xs font-mono whitespace-pre-wrap">
<pre>
<code>{cleanedSql}</code>
</pre>
</div>
)}
{error ? (
<div className="text-red-700 dark:text-red-200 bg-red-100 dark:bg-red-900/50 border border-red-300 dark:border-red-800 rounded p-2">
<strong>Hata:</strong> {error}
</div>
) : typeof answer === 'string' ? (
renderFormattedText(answer)
) : 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 border-gray-200 dark:border-gray-700 rounded">
<table className="table-auto w-full text-sm">
<thead className="bg-gray-100 dark:bg-gray-800 sticky top-0">
<tr>
{Object.keys(answer[0]).map((col) => (
<th
key={col}
className="border border-gray-200 dark:border-gray-700 px-2 py-1 text-left"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{answer.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800">
{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 border-gray-200 dark:border-gray-700 px-2 py-1"
>
{display}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
) : typeof answer === 'object' && answer !== null && (answer as any).message ? (
renderFormattedText(String((answer as any).message))
) : (
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded border border-gray-200 dark:border-gray-700">
{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 border-gray-200 dark:border-gray-700"
/>
</div>
)}
</div>
)
}
return message.role === 'assistant' ? (
<div className="text-sm">{renderFormattedText(String(message.content))}</div>
) : (
<div className="whitespace-pre-wrap text-sm">{String(message.content)}</div>
)
}
// Render
return (
<Container>
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::' + AI_ASSISTANT)}
defaultTitle={APP_NAME}
></Helmet>
<Drawer
isOpen={isConversationDrawerOpen}
onClose={() => setIsConversationDrawerOpen(false)}
onRequestClose={() => setIsConversationDrawerOpen(false)}
placement="left"
title={translate('::App.Platform.Chats')}
width={320}
bodyClass="p-3"
>
<Button block icon={<FaPlus />} onClick={handleNewConversation} disabled={loading}>
{translate('::App.Platform.NewChat')}
</Button>
<div className="mt-3 max-h-[calc(100vh-140px)] overflow-y-auto overscroll-contain">
<div className="space-y-1">
{conversations.map((conversation) => (
<button
key={conversation.id}
type="button"
onClick={() => handleSelectConversation(conversation.id)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-3 text-left text-sm transition-colors ${
conversation.id === activeConversationId
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700'
}`}
>
<FaComment className="shrink-0" />
<span className="truncate">{conversation.title}</span>
<span
role="button"
tabIndex={0}
aria-label={`${conversation.title} sohbetini sil`}
onClick={(event) => {
event.stopPropagation()
handleDeleteConversation(conversation.id)
}}
className="ml-auto shrink-0 rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600 dark:text-gray-500 dark:hover:bg-red-900/40 dark:hover:text-red-300"
>
<FaTrash className="text-xs" />
</span>
</button>
))}
</div>
</div>
</Drawer>
<div className="h-[calc(100vh-140px)] pt-2 flex gap-4">
<aside className="hidden w-64 shrink-0 flex-col rounded-xl border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800 lg:flex">
<Button block icon={<FaPlus />} onClick={handleNewConversation} disabled={loading}>
{translate('::App.Platform.NewChat')}
</Button>
<ScrollBar autoHide className="mt-3 flex-1 min-h-0">
<div className="space-y-1 pr-2">
{conversations.map((conversation) => (
<button
key={conversation.id}
type="button"
onClick={() => handleSelectConversation(conversation.id)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
conversation.id === activeConversationId
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700'
}`}
>
<FaComment className="shrink-0" />
<span className="truncate">{conversation.title}</span>
<span
role="button"
tabIndex={0}
aria-label={`${conversation.title} sohbetini sil`}
title="Sohbeti sil"
onClick={(event) => {
event.stopPropagation()
handleDeleteConversation(conversation.id)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
handleDeleteConversation(conversation.id)
}
}}
className="ml-auto shrink-0 rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600 dark:text-gray-500 dark:hover:bg-red-900/40 dark:hover:text-red-300"
>
<FaTrash className="text-xs" />
</span>
</button>
))}
</div>
</ScrollBar>
</aside>
<div className="min-w-0 flex flex-1 flex-col">
<div className="mb-2 flex shrink-0 gap-2 lg:hidden">
<Button
className="flex-1"
size="sm"
icon={<FaPlus />}
onClick={handleNewConversation}
disabled={loading}
>
{translate('::App.Platform.NewChat')}
</Button>
<Button
className="flex-1"
size="sm"
icon={<FaComment />}
onClick={() => setIsConversationDrawerOpen(true)}
>
{translate('::App.Platform.Chats')}
</Button>
</div>
<ScrollBar
autoHide
autoHideTimeout={700}
autoHideDuration={200}
hideTracksWhenNotNeeded
className="flex-1 min-h-0 min-w-0"
renderView={({ style, ...props }) => (
<div {...props} style={{ ...style, overflowX: 'hidden' }} />
)}
renderThumbVertical={({ style, ...props }) => (
<div
{...props}
className="rounded-full bg-gray-300/80 dark:bg-gray-600/80"
style={{ ...style, width: 6, right: 3 }}
/>
)}
>
<div className="p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
<FaRobot className="w-12 h-12 mx-auto mb-4 text-gray-400 dark:text-gray-500" />
<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%] min-w-0 rounded-lg p-2 ${msg.role === 'user' ? 'bg-blue-500 text-white' : 'bg-white text-gray-800 dark:bg-gray-800 dark:text-gray-100'}`}
>
<div className="flex flex-col gap-1">
{renderMessageContent(msg)}
{msg.createdAt && (
<div
className={`text-[11px] leading-none ${msg.role === 'user' ? 'text-white/80 text-right' : 'text-gray-500 dark:text-gray-400 text-right'}`}
>
{dayjs(msg.createdAt).format('DD.MM.YYYY HH:mm')}
</div>
)}
</div>
</div>
</div>
))}
{loading && (
<div className="flex items-center justify-center gap-2 text-gray-500 dark:text-gray-400">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-500 dark:border-gray-400 border-t-transparent" />
{translate('::AI.WaitAnswer')}
</div>
)}
<div ref={bottomRef}></div>
</div>
</ScrollBar>
<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}
autoFocus
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.id === selectedBot)?.name ||
translate('::AI.SelectModel')
}
>
{bot
.filter((item) => !!item.id)
.map((item) => (
<Dropdown.Item key={item.id!} eventKey={item.id!} 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 size="sm" variant="plain" type="submit" disabled={loading}>
<FaPaperPlane />
</Button>
</div>
</div>
</form>
</div>
</div>
</Container>
)
}
export default Assistant