2026-08-11 13:45:08 +00:00
|
|
|
|
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
|
|
import { FaComment, FaPaperPlane, FaPlus, FaRobot } from 'react-icons/fa'
|
|
|
|
|
|
import { Helmet } from 'react-helmet'
|
|
|
|
|
|
import { useStoreState } from '@/store'
|
|
|
|
|
|
import { Button, Drawer, Dropdown, ScrollBar, Spinner } from '@/components/ui'
|
|
|
|
|
|
import { Container } from '@/components/shared'
|
2026-02-24 20:44:16 +00:00
|
|
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
|
|
|
|
import { aiService } from '@/services/ai.service'
|
|
|
|
|
|
import { APP_NAME } from '@/constants/app.constant'
|
2026-03-28 08:23:21 +00:00
|
|
|
|
import { AI_ASSISTANT } from '@/constants/permission.constant'
|
2026-08-11 13:45:08 +00:00
|
|
|
|
import ConversationList from './components/ConversationList'
|
|
|
|
|
|
import MessageBubble from './components/MessageBubble'
|
|
|
|
|
|
import { useAiBots } from './hooks/useAiBots'
|
|
|
|
|
|
import { useConversations } from './hooks/useConversations'
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
|
|
|
|
|
const Assistant = () => {
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const { translate } = useLocalization()
|
|
|
|
|
|
const { user, tenant } = useStoreState((state) => state.auth)
|
|
|
|
|
|
const { bots, selectedBot, selectedBotId, setSelectedBotId } = useAiBots()
|
|
|
|
|
|
const {
|
|
|
|
|
|
conversations,
|
|
|
|
|
|
activeConversationId,
|
|
|
|
|
|
activeConversation,
|
|
|
|
|
|
messages,
|
|
|
|
|
|
appendMessage,
|
|
|
|
|
|
startNewConversation,
|
|
|
|
|
|
selectConversation,
|
|
|
|
|
|
deleteConversation,
|
|
|
|
|
|
} = useConversations()
|
|
|
|
|
|
|
2026-06-20 09:09:37 +00:00
|
|
|
|
const [isConversationDrawerOpen, setIsConversationDrawerOpen] = useState(false)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
const [input, setInput] = useState('')
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
|
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)
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const requestRef = useRef<AbortController | null>(null)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
// Açık istek varken sayfadan çıkılırsa iptal et.
|
|
|
|
|
|
useEffect(() => () => requestRef.current?.abort(), [])
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-08-11 13:45:08 +00:00
|
|
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
|
|
|
|
|
}, [messages, loading])
|
2026-06-20 09:09:37 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-08-11 13:45:08 +00:00
|
|
|
|
if (!loading) inputRef.current?.focus()
|
|
|
|
|
|
}, [loading])
|
2026-06-20 09:09:37 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const focusInput = () => requestAnimationFrame(() => inputRef.current?.focus())
|
2026-06-20 09:09:37 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const handleNewConversation = useCallback(() => {
|
2026-06-20 09:09:37 +00:00
|
|
|
|
if (loading) return
|
2026-08-11 13:45:08 +00:00
|
|
|
|
startNewConversation()
|
2026-06-20 09:09:37 +00:00
|
|
|
|
setInput('')
|
|
|
|
|
|
setIsConversationDrawerOpen(false)
|
2026-08-11 13:45:08 +00:00
|
|
|
|
focusInput()
|
|
|
|
|
|
}, [loading, startNewConversation])
|
2026-06-20 09:09:37 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const handleSelectConversation = useCallback(
|
|
|
|
|
|
(conversationId: string) => {
|
|
|
|
|
|
if (loading) return
|
|
|
|
|
|
selectConversation(conversationId)
|
|
|
|
|
|
setIsConversationDrawerOpen(false)
|
|
|
|
|
|
focusInput()
|
|
|
|
|
|
},
|
|
|
|
|
|
[loading, selectConversation],
|
|
|
|
|
|
)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const handleDeleteConversation = useCallback(
|
|
|
|
|
|
(conversationId: string) => {
|
|
|
|
|
|
if (loading) return
|
|
|
|
|
|
deleteConversation(conversationId)
|
|
|
|
|
|
if (conversationId === activeConversationId) setInput('')
|
|
|
|
|
|
},
|
|
|
|
|
|
[loading, deleteConversation, activeConversationId],
|
|
|
|
|
|
)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const handleSubmit = async (event: FormEvent) => {
|
|
|
|
|
|
event.preventDefault()
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const question = input.trim()
|
|
|
|
|
|
if (loading || !question || !activeConversation) return
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-06-20 09:09:37 +00:00
|
|
|
|
const conversationId = activeConversation.id
|
2026-02-24 20:44:16 +00:00
|
|
|
|
setInput('')
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
|
2026-06-20 09:09:37 +00:00
|
|
|
|
appendMessage(conversationId, {
|
|
|
|
|
|
role: 'user',
|
2026-08-11 13:45:08 +00:00
|
|
|
|
content: question,
|
|
|
|
|
|
createdAt: new Date().toISOString(),
|
2026-06-20 09:09:37 +00:00
|
|
|
|
})
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
requestRef.current = controller
|
2026-03-21 16:59:22 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
try {
|
|
|
|
|
|
if (!selectedBot?.apiUrl?.trim()) {
|
|
|
|
|
|
throw new Error('AI bot url is not configured.')
|
2026-03-21 16:59:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const content = await aiService.askBot(
|
|
|
|
|
|
selectedBot.apiUrl,
|
|
|
|
|
|
{
|
2026-06-20 09:09:37 +00:00
|
|
|
|
biletId: conversationId,
|
2026-08-11 13:45:08 +00:00
|
|
|
|
question,
|
2026-06-20 09:09:37 +00:00
|
|
|
|
sessionId: conversationId,
|
2026-08-11 13:45:08 +00:00
|
|
|
|
tenantName: tenant?.tenantName ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
controller.signal,
|
|
|
|
|
|
)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-06-20 09:09:37 +00:00
|
|
|
|
appendMessage(conversationId, {
|
|
|
|
|
|
role: 'assistant',
|
2026-08-11 13:45:08 +00:00
|
|
|
|
content,
|
|
|
|
|
|
createdAt: new Date().toISOString(),
|
2026-06-20 09:09:37 +00:00
|
|
|
|
})
|
2026-08-11 13:45:08 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (controller.signal.aborted) return
|
|
|
|
|
|
|
|
|
|
|
|
console.error('AI bot request failed:', error)
|
2026-06-20 09:09:37 +00:00
|
|
|
|
appendMessage(conversationId, {
|
|
|
|
|
|
role: 'assistant',
|
2026-08-11 13:45:08 +00:00
|
|
|
|
content: selectedBot ? translate('::AI.RequestFailed') : translate('::AI.SelectModel'),
|
|
|
|
|
|
createdAt: new Date().toISOString(),
|
2026-06-20 09:09:37 +00:00
|
|
|
|
})
|
2026-08-11 13:45:08 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
if (requestRef.current === controller) {
|
|
|
|
|
|
requestRef.current = null
|
|
|
|
|
|
setLoading(false)
|
2026-02-24 20:44:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-11 13:45:08 +00:00
|
|
|
|
}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
const conversationListProps = {
|
|
|
|
|
|
conversations,
|
|
|
|
|
|
activeConversationId,
|
|
|
|
|
|
disabled: loading,
|
|
|
|
|
|
onSelect: handleSelectConversation,
|
|
|
|
|
|
onDelete: handleDeleteConversation,
|
2026-02-24 20:44:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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}
|
2026-08-11 13:45:08 +00:00
|
|
|
|
/>
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-06-20 09:09:37 +00:00
|
|
|
|
<Drawer
|
|
|
|
|
|
isOpen={isConversationDrawerOpen}
|
|
|
|
|
|
placement="left"
|
|
|
|
|
|
title={translate('::App.Platform.Chats')}
|
|
|
|
|
|
width={320}
|
|
|
|
|
|
bodyClass="p-3"
|
2026-08-11 13:45:08 +00:00
|
|
|
|
onClose={() => setIsConversationDrawerOpen(false)}
|
|
|
|
|
|
onRequestClose={() => setIsConversationDrawerOpen(false)}
|
2026-06-20 09:09:37 +00:00
|
|
|
|
>
|
2026-08-11 13:45:08 +00:00
|
|
|
|
<Button block icon={<FaPlus />} disabled={loading} onClick={handleNewConversation}>
|
2026-06-20 09:09:37 +00:00
|
|
|
|
{translate('::App.Platform.NewChat')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<div className="mt-3 max-h-[calc(100vh-140px)] overflow-y-auto overscroll-contain">
|
2026-08-11 13:45:08 +00:00
|
|
|
|
<ConversationList {...conversationListProps} />
|
2026-06-20 09:09:37 +00:00
|
|
|
|
</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">
|
2026-08-11 13:45:08 +00:00
|
|
|
|
<Button block icon={<FaPlus />} disabled={loading} onClick={handleNewConversation}>
|
2026-06-20 09:09:37 +00:00
|
|
|
|
{translate('::App.Platform.NewChat')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<ScrollBar autoHide className="mt-3 flex-1 min-h-0">
|
2026-08-11 13:45:08 +00:00
|
|
|
|
<div className="pr-2">
|
|
|
|
|
|
<ConversationList {...conversationListProps} />
|
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 />}
|
|
|
|
|
|
disabled={loading}
|
2026-08-11 13:45:08 +00:00
|
|
|
|
onClick={handleNewConversation}
|
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>
|
2026-08-11 13:45:08 +00:00
|
|
|
|
|
2026-06-20 09:09:37 +00:00
|
|
|
|
<ScrollBar
|
|
|
|
|
|
autoHide
|
2026-08-11 13:45:08 +00:00
|
|
|
|
hideTracksWhenNotNeeded
|
2026-06-20 09:09:37 +00:00
|
|
|
|
autoHideTimeout={700}
|
|
|
|
|
|
autoHideDuration={200}
|
|
|
|
|
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
{messages.map((message, index) => (
|
|
|
|
|
|
<MessageBubble
|
|
|
|
|
|
key={`${activeConversationId}-${index}`}
|
|
|
|
|
|
message={message}
|
|
|
|
|
|
avatar={user?.avatar}
|
|
|
|
|
|
/>
|
2026-06-20 09:09:37 +00:00
|
|
|
|
))}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
{loading && (
|
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
|
<Spinner size={20} />
|
|
|
|
|
|
<span>{translate('::AI.WaitAnswer')}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
<div ref={bottomRef} />
|
2026-02-24 20:44:16 +00:00
|
|
|
|
</div>
|
2026-06-20 09:09:37 +00:00
|
|
|
|
</ScrollBar>
|
|
|
|
|
|
|
2026-08-11 13:45:08 +00:00
|
|
|
|
<form className="w-full mx-auto px-4" onSubmit={handleSubmit}>
|
2026-06-23 23:23:23 +00:00
|
|
|
|
<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}
|
|
|
|
|
|
autoFocus
|
2026-08-11 13:45:08 +00:00
|
|
|
|
value={input}
|
|
|
|
|
|
disabled={loading}
|
2026-06-20 09:09:37 +00:00
|
|
|
|
placeholder={translate('::AI.Asking')}
|
2026-08-11 13:45:08 +00:00
|
|
|
|
className="min-w-0 flex-1 bg-transparent px-1 text-base outline-none placeholder-gray-400 disabled:cursor-not-allowed"
|
|
|
|
|
|
onChange={(event) => setInput(event.target.value)}
|
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-08-11 13:45:08 +00:00
|
|
|
|
title={selectedBot?.name || translate('::AI.SelectModel')}
|
2026-06-20 09:09:37 +00:00
|
|
|
|
>
|
2026-08-11 13:45:08 +00:00
|
|
|
|
{bots.map((item) => (
|
|
|
|
|
|
<Dropdown.Item
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
eventKey={item.id}
|
|
|
|
|
|
onSelect={(eventKey: string) => setSelectedBotId(eventKey)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.name}
|
|
|
|
|
|
</Dropdown.Item>
|
|
|
|
|
|
))}
|
2026-06-20 09:09:37 +00:00
|
|
|
|
</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 />}
|
2026-08-11 13:45:08 +00:00
|
|
|
|
disabled={loading || !input.trim() || !selectedBotId}
|
2026-06-23 23:23:23 +00:00
|
|
|
|
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
|