sozsoft-platform/ui/src/views/ai/Assistant.tsx
Sedat Öztürk b1bd573905 n8n Workflow
2026-06-24 23:46:32 +03:00

811 lines
31 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 {
AiBotDto,
BaseContent,
Conversation,
Message,
MessageContent,
SqlResultRow,
} from '@/proxy/ai/models'
import { Container, Loading } 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'
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<AiBotDto[]>([])
const [selectedBot, setSelectedBot] = useState<string | null>(null)
const { user, tenant } = useStoreState((state) => state.auth)
const inputRef = useRef<HTMLInputElement | 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,
tenantName: tenant.tenantName ?? null,
}),
})
if (!response.ok) {
throw new Error(`AI bot request failed: ${response.status}`)
}
const data = await response.json()
const raw = Array.isArray(data) ? data[0] : data
const mapped: BaseContent = {
type: raw?.type || 'chat',
question: raw?.question || userMessage,
sql: raw?.sql ?? null,
answer: raw?.answer ?? null,
columns: Array.isArray(raw?.columns) ? raw.columns : undefined,
rows: Array.isArray(raw?.rows) ? raw.rows : undefined,
rowCount: typeof raw?.rowCount === 'number' ? raw.rowCount : undefined,
chart: raw?.chart,
error: raw?.error ?? null,
}
// 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, question, sql, answer, chart, error, columns, rows, rowCount } =
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))))
: []
const typeStyles: Record<string, string> = {
chat: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200',
sql_result: 'bg-gray-100 text-gray-700 dark:bg-gray-900/50 dark:text-gray-200',
}
const typeLabels: Record<string, string> = {
chat: '🗨️ ' + translate('::AI.SohbetAnswer'),
sql_result: `📊 ${translate('::AI.DatabaseAnswer')}`,
}
const cleanedSql = (() => {
try {
const rawSql = decodeURIComponent(sql || '')
return rawSql.replace(/^```sql\n?/, '').replace(/```$/, '')
} catch {
return sql
}
})()
return (
<div className="space-y-3 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 && !isSqlResult && (
<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>
)}
{!error &&
(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">
<span>
Oluşturulan SQL sorgusunu göster ({rowCount ?? resultRows.length} kayıt)
</span>
<span className="text-base leading-none transition-transform group-open:rotate-45">
+
</span>
</summary>
<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">
<code>{cleanedSql}</code>
</pre>
</details>
)}
{resultRows.length === 0 ? (
<div className="px-4 py-6 text-center text-gray-500 italic">
Sonuç bulunamadı.
</div>
) : (
<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">
<table className="w-full min-w-max text-sm">
<thead className="sticky top-0 z-10 bg-gray-800 text-xs tracking-wide text-gray-100 shadow-sm dark:bg-gray-950">
<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' ? (
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 ? (
<div className="text-gray-500 italic">Sonuç bulunamadı.</div>
) : (
<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={user.avatar} alt="avatar" />
) : (
<FaRobot className="w-5 h-5 text-white" />
)}
</div>
<div
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'}`}
>
<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 type="default" loading={loading}></Loading>
<div ref={bottomRef}></div>
</div>
</ScrollBar>
<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
ref={inputRef}
value={input}
autoFocus
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit(e)
}
}}
placeholder={translate('::AI.Asking')}
className="min-w-0 flex-1 bg-transparent px-1 text-base outline-none placeholder-gray-400"
/>
<div className="shrink-0 text-sm">
<Dropdown
placement="top-end"
menuClass="w-48 max-w-[calc(100vw-2rem)]"
toggleClassName="[&_svg]:ml-2"
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="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"
/>
</div>
</div>
</form>
</div>
</div>
</Container>
)
}
export default Assistant