sozsoft-platform/ui/src/views/ai/Assistant.tsx

777 lines
30 KiB
TypeScript
Raw Normal View History

2026-06-20 09:09:37 +00:00
import React, { useState, useRef, useEffect } from 'react'
import { FaComment, FaPaperPlane, FaPlus, FaRobot, FaTrash } from 'react-icons/fa'
import { useStoreActions, useStoreState } from '@/store'
2026-06-20 09:09:37 +00:00
import { Avatar, Button, Drawer, Dropdown, ScrollBar } from '@/components/ui'
2026-02-24 20:44:16 +00:00
import { useLocalization } from '@/utils/hooks/useLocalization'
import { aiService } from '@/services/ai.service'
2026-06-23 23:23:23 +00:00
import {
AiBotDto,
BaseContent,
Conversation,
Message,
MessageContent,
SqlResultRow,
} from '@/proxy/ai/models'
2026-06-24 16:59:51 +00:00
import { Container, Loading } from '@/components/shared'
2026-02-24 20:44:16 +00:00
import { Helmet } from 'react-helmet'
import { APP_NAME } from '@/constants/app.constant'
import dayjs from 'dayjs'
2026-03-28 08:23:21 +00:00
import { AI_ASSISTANT } from '@/constants/permission.constant'
2026-02-24 20:44:16 +00:00
2026-06-20 09:09:37 +00:00
const getConversationTitle = (message: string) =>
message.length > 42 ? `${message.slice(0, 42).trimEnd()}` : message
2026-02-24 20:44:16 +00:00
const isContentObject = (content: MessageContent): content is BaseContent =>
typeof content !== 'string' && 'type' in content
2026-06-20 09:09:37 +00:00
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>
)
}
2026-02-24 20:44:16 +00:00
// Main Component
const Assistant = () => {
// Hooks
const storedAiConversations = useStoreState((state) => state.admin.aiConversations)
const { setAiConversations } = useStoreActions((actions) => actions.admin)
const [conversations, setConversations] = useState<Conversation[]>(storedAiConversations.items)
const [activeConversationId, setActiveConversationId] = useState<string | null>(
storedAiConversations.aiActiveConversationId,
)
2026-06-20 09:09:37 +00:00
const [isConversationsLoaded, setIsConversationsLoaded] = useState(false)
const [isConversationDrawerOpen, setIsConversationDrawerOpen] = useState(false)
2026-02-24 20:44:16 +00:00
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [bot, setBot] = useState<AiBotDto[]>([])
2026-02-24 20:44:16 +00:00
const [selectedBot, setSelectedBot] = useState<string | null>(null)
const { user, tenant } = useStoreState((state) => state.auth)
2026-06-23 23:23:23 +00:00
const inputRef = useRef<HTMLInputElement | null>(null)
2026-02-24 20:44:16 +00:00
const bottomRef = useRef<HTMLDivElement | null>(null)
const { translate } = useLocalization()
2026-06-20 09:09:37 +00:00
const createConversation = (): Conversation => {
const now = new Date().toISOString()
return {
id: crypto.randomUUID(),
title: translate('::App.Platform.NewChat'),
messages: [],
createdAt: now,
updatedAt: now,
}
}
2026-02-24 20:44:16 +00:00
useEffect(() => {
if (conversations.length === 0) {
2026-06-20 09:09:37 +00:00
const conversation = createConversation()
setConversations([conversation])
setActiveConversationId(conversation.id)
} else if (!conversations.some((conversation) => conversation.id === activeConversationId)) {
setActiveConversationId(conversations[0].id)
2026-06-20 09:09:37 +00:00
}
setIsConversationsLoaded(true)
}, [])
useEffect(() => {
if (!isConversationsLoaded) return
setAiConversations({
items: conversations,
aiActiveConversationId: activeConversationId,
})
}, [activeConversationId, conversations, isConversationsLoaded, setAiConversations])
2026-06-20 09:09:37 +00:00
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('')
}
}
2026-02-24 20:44:16 +00:00
// Botları çek
useEffect(() => {
const fetchBots = async () => {
try {
const result = await aiService.getList({
skipCount: 0,
maxResultCount: 1000,
sorting: 'name',
})
2026-03-21 16:59:22 +00:00
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)
2026-02-24 20:44:16 +00:00
} 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()
2026-06-20 09:09:37 +00:00
if (!input.trim() || !activeConversation) return
2026-02-24 20:44:16 +00:00
const userMessage = input.trim()
2026-06-20 09:09:37 +00:00
const conversationId = activeConversation.id
const userMessageCreatedAt = new Date().toISOString()
2026-02-24 20:44:16 +00:00
setInput('')
setLoading(true)
// 1⃣ Soruyu store'a ekle
2026-06-20 09:09:37 +00:00
appendMessage(conversationId, {
role: 'user',
content: userMessage,
createdAt: userMessageCreatedAt,
})
2026-02-24 20:44:16 +00:00
try {
2026-03-21 16:59:22 +00:00
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, {
2026-02-24 20:44:16 +00:00
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
2026-06-20 09:09:37 +00:00
biletId: conversationId,
2026-02-24 20:44:16 +00:00
question: userMessage,
2026-06-20 09:09:37 +00:00
sessionId: conversationId,
tenantName: tenant.tenantName ?? null,
2026-02-24 20:44:16 +00:00
}),
})
2026-06-23 23:23:23 +00:00
if (!response.ok) {
throw new Error(`AI bot request failed: ${response.status}`)
}
2026-02-24 20:44:16 +00:00
const data = await response.json()
const raw = Array.isArray(data) ? data[0] : data
const mapped: BaseContent = {
2026-06-23 17:30:19 +00:00
type: raw?.type || 'chat',
question: raw?.question || userMessage,
sql: raw?.sql ?? null,
answer: raw?.answer ?? null,
2026-06-23 23:23:23 +00:00
columns: Array.isArray(raw?.columns) ? raw.columns : undefined,
rows: Array.isArray(raw?.rows) ? raw.rows : undefined,
rowCount: typeof raw?.rowCount === 'number' ? raw.rowCount : undefined,
2026-06-23 17:30:19 +00:00
chart: raw?.chart,
error: raw?.error ?? null,
2026-02-24 20:44:16 +00:00
}
// 2⃣ Cevabı store'a ekle
const assistantMessageCreatedAt = new Date().toISOString()
2026-02-24 20:44:16 +00:00
2026-06-20 09:09:37 +00:00
appendMessage(conversationId, {
role: 'assistant',
content: mapped,
createdAt: assistantMessageCreatedAt,
})
2026-02-24 20:44:16 +00:00
} catch {
const errorMessage = 'Üzgünüm, bir hata oluştu. Lütfen tekrar deneyin.'
const assistantMessageCreatedAt = new Date().toISOString()
2026-06-20 09:09:37 +00:00
appendMessage(conversationId, {
role: 'assistant',
content: errorMessage,
createdAt: assistantMessageCreatedAt,
})
2026-02-24 20:44:16 +00:00
}
setLoading(false)
}
// Mesaj içeriği render
const renderMessageContent = (message: Message) => {
if (message.role === 'assistant' && isContentObject(message.content)) {
2026-07-06 09:44:26 +00:00
const { type, sql, answer, chart, error, columns, rows, rowCount } =
2026-06-23 23:23:23 +00:00
message.content as BaseContent & {
error?: string
}
const isSqlResult = type === 'sql_result'
const resultRows: SqlResultRow[] = isSqlResult
? Array.isArray(rows)
? rows
: []
: Array.isArray(answer)
? answer
: []
const resultColumns =
Array.isArray(columns) && columns.length > 0
? columns
: resultRows.length > 0
? Array.from(new Set(resultRows.flatMap((row) => Object.keys(row))))
: []
2026-02-24 20:44:16 +00:00
const typeStyles: Record<string, string> = {
2026-06-20 09:09:37 +00:00
chat: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200',
2026-06-23 23:23:23 +00:00
sql_result: 'bg-gray-100 text-gray-700 dark:bg-gray-900/50 dark:text-gray-200',
2026-02-24 20:44:16 +00:00
}
const typeLabels: Record<string, string> = {
chat: '🗨️ ' + translate('::AI.SohbetAnswer'),
2026-06-23 23:23:23 +00:00
sql_result: `📊 ${translate('::AI.DatabaseAnswer')}`,
2026-02-24 20:44:16 +00:00
}
const cleanedSql = (() => {
try {
const rawSql = decodeURIComponent(sql || '')
return rawSql.replace(/^```sql\n?/, '').replace(/```$/, '')
} catch {
return sql
}
})()
return (
2026-06-23 23:23:23 +00:00
<div className="space-y-3 text-sm">
2026-02-24 20:44:16 +00:00
<div
2026-06-20 09:09:37 +00:00
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'}`}
2026-02-24 20:44:16 +00:00
>
{typeLabels[type] || type}
</div>
2026-06-23 23:23:23 +00:00
{sql && !isSqlResult && (
2026-06-20 09:09:37 +00:00
<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">
2026-02-24 20:44:16 +00:00
<pre>
<code>{cleanedSql}</code>
</pre>
</div>
)}
2026-06-23 17:30:19 +00:00
{error && (
2026-06-20 09:09:37 +00:00
<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">
2026-02-24 20:44:16 +00:00
<strong>Hata:</strong> {error}
</div>
2026-06-23 17:30:19 +00:00
)}
{!error &&
2026-06-23 23:23:23 +00:00
(isSqlResult ? (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-900/70 dark:bg-gray-800">
{sql && (
<details className="group border-b border-gray-100 px-4 py-2.5 dark:border-gray-700">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-xs font-medium text-gray-500 marker:content-none hover:text-emerald-700 dark:text-gray-400 dark:hover:text-emerald-300">
2026-06-24 16:59:51 +00:00
<span>
Oluşturulan SQL sorgusunu göster ({rowCount ?? resultRows.length} kayıt)
</span>
2026-06-23 23:23:23 +00:00
<span className="text-base leading-none transition-transform group-open:rotate-45">
+
</span>
</summary>
2026-06-24 20:46:32 +00:00
<pre className="mt-3 max-h-48 overflow-auto rounded-lg bg-gray-950 p-3 text-xs leading-5 text-emerald-100 [scrollbar-color:#6b7280_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-500 [&::-webkit-scrollbar-thumb:hover]:bg-gray-400 [&::-webkit-scrollbar-track]:bg-transparent">
2026-06-23 23:23:23 +00:00
<code>{cleanedSql}</code>
</pre>
</details>
)}
{resultRows.length === 0 ? (
<div className="px-4 py-6 text-center text-gray-500 italic">
Sonuç bulunamadı.
</div>
) : (
2026-06-24 20:46:32 +00:00
<div className="max-h-[400px] overflow-auto pb-1 [scrollbar-color:#6b7280_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-400 [&::-webkit-scrollbar-thumb:hover]:bg-gray-500 [&::-webkit-scrollbar-track]:bg-transparent dark:[scrollbar-color:#6b7280_transparent] dark:[&::-webkit-scrollbar-thumb]:bg-gray-500 dark:[&::-webkit-scrollbar-thumb:hover]:bg-gray-400">
2026-06-23 23:23:23 +00:00
<table className="w-full min-w-max text-sm">
2026-06-24 16:59:51 +00:00
<thead className="sticky top-0 z-10 bg-gray-800 text-xs tracking-wide text-gray-100 shadow-sm dark:bg-gray-950">
2026-06-23 23:23:23 +00:00
<tr>
{resultColumns.map((column) => (
<th
key={column}
className="whitespace-nowrap border-b border-r border-indigo-400/20 px-4 py-3 text-left font-semibold last:border-r-0"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{resultRows.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-b border-gray-100 even:bg-slate-50/80 hover:bg-emerald-50/70 dark:border-gray-700 dark:even:bg-gray-800/60 dark:hover:bg-emerald-950/20"
>
{resultColumns.map((column) => {
const value = row[column]
const display =
value === null || value === undefined
? '—'
: typeof value === 'boolean'
? value
? 'Evet'
: 'Hayır'
: typeof value === 'object'
? JSON.stringify(value)
: String(value)
return (
<td
key={column}
className="whitespace-nowrap border-r border-gray-100 px-2 py-2 align-top text-gray-700 first:font-medium first:text-slate-900 last:border-r-0 dark:border-gray-700 dark:text-gray-200 dark:first:text-white"
>
{display}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
) : typeof answer === 'string' ? (
2026-06-23 17:30:19 +00:00
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))
) : answer === null || answer === undefined ? (
2026-02-24 20:44:16 +00:00
<div className="text-gray-500 italic">Sonuç bulunamadı.</div>
) : (
2026-06-23 17:30:19 +00:00
<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>
))}
2026-02-24 20:44:16 +00:00
{chart && (
<div className="mt-4">
<img
src={`data:image/png;base64,${chart}`}
alt="Grafik"
2026-06-20 09:09:37 +00:00
className="max-w-full rounded border border-gray-200 dark:border-gray-700"
2026-02-24 20:44:16 +00:00
/>
</div>
)}
</div>
)
}
2026-06-20 09:09:37 +00:00
return message.role === 'assistant' ? (
<div className="text-sm">{renderFormattedText(String(message.content))}</div>
) : (
<div className="whitespace-pre-wrap text-sm">{String(message.content)}</div>
)
2026-02-24 20:44:16 +00:00
}
// Render
return (
<Container>
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
2026-03-28 08:23:21 +00:00
title={translate('::' + AI_ASSISTANT)}
2026-02-24 20:44:16 +00:00
defaultTitle={APP_NAME}
></Helmet>
2026-06-20 09:09:37 +00:00
<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
2026-06-20 09:09:37 +00:00
key={conversation.id}
type="button"
variant="plain"
shape="none"
2026-06-20 09:09:37 +00:00
onClick={() => handleSelectConversation(conversation.id)}
className={`!inline-flex !h-auto !w-full items-center gap-2 rounded-lg !px-3 py-3 text-left text-sm transition-colors ${
2026-06-20 09:09:37 +00:00
conversation.id === activeConversationId
? '!bg-blue-50 text-blue-700 dark:!bg-blue-900/40 dark:text-blue-200'
: '!bg-transparent text-gray-700 hover:!bg-gray-100 dark:text-gray-200 dark:hover:!bg-gray-700'
2026-06-20 09:09:37 +00:00
}`}
>
<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>
2026-06-20 09:09:37 +00:00
))}
</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
2026-06-20 09:09:37 +00:00
key={conversation.id}
type="button"
variant="plain"
shape="none"
2026-06-20 09:09:37 +00:00
onClick={() => handleSelectConversation(conversation.id)}
className={`!inline-flex !h-auto !w-full items-center gap-2 rounded-lg !px-3 py-2 text-left text-sm transition-colors ${
2026-06-20 09:09:37 +00:00
conversation.id === activeConversationId
? '!bg-blue-50 text-blue-700 dark:!bg-blue-900/40 dark:text-blue-200'
: '!bg-transparent text-gray-700 hover:!bg-gray-100 dark:text-gray-200 dark:hover:!bg-gray-700'
2026-06-20 09:09:37 +00:00
}`}
>
<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>
2026-06-20 09:09:37 +00:00
))}
2026-02-24 20:44:16 +00:00
</div>
2026-06-20 09:09:37 +00:00
</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}
2026-02-24 20:44:16 +00:00
>
2026-06-20 09:09:37 +00:00
{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 }) => (
2026-02-24 20:44:16 +00:00
<div
2026-06-20 09:09:37 +00:00
{...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={user.avatar} alt="avatar" />
2026-06-20 09:09:37 +00:00
) : (
<FaRobot className="w-5 h-5 text-white" />
)}
</div>
<div
2026-06-24 20:46:32 +00:00
className={`max-w-[90%] 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'}`}
2026-06-20 09:09:37 +00:00
>
<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>
2026-06-20 09:09:37 +00:00
</div>
</div>
2026-06-20 09:09:37 +00:00
))}
2026-02-24 20:44:16 +00:00
2026-06-24 16:59:51 +00:00
<Loading type="default" loading={loading}></Loading>
2026-02-24 20:44:16 +00:00
2026-06-20 09:09:37 +00:00
<div ref={bottomRef}></div>
2026-02-24 20:44:16 +00:00
</div>
2026-06-20 09:09:37 +00:00
</ScrollBar>
2026-06-23 23:23:23 +00:00
<form onSubmit={handleSubmit} className="w-full mx-auto px-4">
<div className="flex w-full items-center gap-2 rounded-2xl bg-[#2a2a2a] px-3 py-2 text-white shadow-md">
<input
2026-06-20 09:09:37 +00:00
ref={inputRef}
value={input}
autoFocus
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
2026-06-23 23:23:23 +00:00
if (e.key === 'Enter') {
2026-06-20 09:09:37 +00:00
e.preventDefault()
handleSubmit(e)
}
}}
placeholder={translate('::AI.Asking')}
2026-06-23 23:23:23 +00:00
className="min-w-0 flex-1 bg-transparent px-1 text-base outline-none placeholder-gray-400"
2026-06-20 09:09:37 +00:00
/>
2026-02-24 20:44:16 +00:00
2026-06-23 23:23:23 +00:00
<div className="shrink-0 text-sm">
2026-06-20 09:09:37 +00:00
<Dropdown
2026-06-23 23:23:23 +00:00
placement="top-end"
menuClass="w-48 max-w-[calc(100vw-2rem)]"
toggleClassName="[&_svg]:ml-2"
2026-06-20 09:09:37 +00:00
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>
2026-06-23 23:23:23 +00:00
<div className="ml-2 flex shrink-0 items-center border-l border-white/15 pl-3">
<Button
size="sm"
shape="circle"
variant="plain"
type="submit"
icon={<FaPaperPlane />}
disabled={loading}
className="bg-white !text-gray-800 shadow-sm hover:bg-gray-100"
/>
2026-06-20 09:09:37 +00:00
</div>
2026-02-24 20:44:16 +00:00
</div>
2026-06-20 09:09:37 +00:00
</form>
</div>
2026-02-24 20:44:16 +00:00
</div>
</Container>
)
}
export default Assistant