297 lines
9.9 KiB
TypeScript
297 lines
9.9 KiB
TypeScript
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'
|
||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||
import { aiService } from '@/services/ai.service'
|
||
import { APP_NAME } from '@/constants/app.constant'
|
||
import { AI_ASSISTANT } from '@/constants/permission.constant'
|
||
import ConversationList from './components/ConversationList'
|
||
import MessageBubble from './components/MessageBubble'
|
||
import { useAiBots } from './hooks/useAiBots'
|
||
import { useConversations } from './hooks/useConversations'
|
||
|
||
const Assistant = () => {
|
||
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()
|
||
|
||
const [isConversationDrawerOpen, setIsConversationDrawerOpen] = useState(false)
|
||
const [input, setInput] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||
const requestRef = useRef<AbortController | null>(null)
|
||
|
||
// Açık istek varken sayfadan çıkılırsa iptal et.
|
||
useEffect(() => () => requestRef.current?.abort(), [])
|
||
|
||
useEffect(() => {
|
||
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||
}, [messages, loading])
|
||
|
||
useEffect(() => {
|
||
if (!loading) inputRef.current?.focus()
|
||
}, [loading])
|
||
|
||
const focusInput = () => requestAnimationFrame(() => inputRef.current?.focus())
|
||
|
||
const handleNewConversation = useCallback(() => {
|
||
if (loading) return
|
||
startNewConversation()
|
||
setInput('')
|
||
setIsConversationDrawerOpen(false)
|
||
focusInput()
|
||
}, [loading, startNewConversation])
|
||
|
||
const handleSelectConversation = useCallback(
|
||
(conversationId: string) => {
|
||
if (loading) return
|
||
selectConversation(conversationId)
|
||
setIsConversationDrawerOpen(false)
|
||
focusInput()
|
||
},
|
||
[loading, selectConversation],
|
||
)
|
||
|
||
const handleDeleteConversation = useCallback(
|
||
(conversationId: string) => {
|
||
if (loading) return
|
||
deleteConversation(conversationId)
|
||
if (conversationId === activeConversationId) setInput('')
|
||
},
|
||
[loading, deleteConversation, activeConversationId],
|
||
)
|
||
|
||
const handleSubmit = async (event: FormEvent) => {
|
||
event.preventDefault()
|
||
|
||
const question = input.trim()
|
||
if (loading || !question || !activeConversation) return
|
||
|
||
const conversationId = activeConversation.id
|
||
setInput('')
|
||
setLoading(true)
|
||
|
||
appendMessage(conversationId, {
|
||
role: 'user',
|
||
content: question,
|
||
createdAt: new Date().toISOString(),
|
||
})
|
||
|
||
const controller = new AbortController()
|
||
requestRef.current = controller
|
||
|
||
try {
|
||
if (!selectedBot?.apiUrl?.trim()) {
|
||
throw new Error('AI bot url is not configured.')
|
||
}
|
||
|
||
const content = await aiService.askBot(
|
||
selectedBot.apiUrl,
|
||
{
|
||
biletId: conversationId,
|
||
question,
|
||
sessionId: conversationId,
|
||
tenantName: tenant?.tenantName ?? null,
|
||
},
|
||
controller.signal,
|
||
)
|
||
|
||
appendMessage(conversationId, {
|
||
role: 'assistant',
|
||
content,
|
||
createdAt: new Date().toISOString(),
|
||
})
|
||
} catch (error) {
|
||
if (controller.signal.aborted) return
|
||
|
||
console.error('AI bot request failed:', error)
|
||
appendMessage(conversationId, {
|
||
role: 'assistant',
|
||
content: selectedBot ? translate('::AI.RequestFailed') : translate('::AI.SelectModel'),
|
||
createdAt: new Date().toISOString(),
|
||
})
|
||
} finally {
|
||
if (requestRef.current === controller) {
|
||
requestRef.current = null
|
||
setLoading(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
const conversationListProps = {
|
||
conversations,
|
||
activeConversationId,
|
||
disabled: loading,
|
||
onSelect: handleSelectConversation,
|
||
onDelete: handleDeleteConversation,
|
||
}
|
||
|
||
return (
|
||
<Container>
|
||
<Helmet
|
||
titleTemplate={`%s | ${APP_NAME}`}
|
||
title={translate('::' + AI_ASSISTANT)}
|
||
defaultTitle={APP_NAME}
|
||
/>
|
||
|
||
<Drawer
|
||
isOpen={isConversationDrawerOpen}
|
||
placement="left"
|
||
title={translate('::App.Platform.Chats')}
|
||
width={320}
|
||
bodyClass="p-3"
|
||
onClose={() => setIsConversationDrawerOpen(false)}
|
||
onRequestClose={() => setIsConversationDrawerOpen(false)}
|
||
>
|
||
<Button block icon={<FaPlus />} disabled={loading} onClick={handleNewConversation}>
|
||
{translate('::App.Platform.NewChat')}
|
||
</Button>
|
||
<div className="mt-3 max-h-[calc(100vh-140px)] overflow-y-auto overscroll-contain">
|
||
<ConversationList {...conversationListProps} />
|
||
</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 />} disabled={loading} onClick={handleNewConversation}>
|
||
{translate('::App.Platform.NewChat')}
|
||
</Button>
|
||
<ScrollBar autoHide className="mt-3 flex-1 min-h-0">
|
||
<div className="pr-2">
|
||
<ConversationList {...conversationListProps} />
|
||
</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 />}
|
||
disabled={loading}
|
||
onClick={handleNewConversation}
|
||
>
|
||
{translate('::App.Platform.NewChat')}
|
||
</Button>
|
||
<Button
|
||
className="flex-1"
|
||
size="sm"
|
||
icon={<FaComment />}
|
||
onClick={() => setIsConversationDrawerOpen(true)}
|
||
>
|
||
{translate('::App.Platform.Chats')}
|
||
</Button>
|
||
</div>
|
||
|
||
<ScrollBar
|
||
autoHide
|
||
hideTracksWhenNotNeeded
|
||
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 }) => (
|
||
<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((message, index) => (
|
||
<MessageBubble
|
||
key={`${activeConversationId}-${index}`}
|
||
message={message}
|
||
avatar={user?.avatar}
|
||
/>
|
||
))}
|
||
|
||
{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>
|
||
)}
|
||
|
||
<div ref={bottomRef} />
|
||
</div>
|
||
</ScrollBar>
|
||
|
||
<form className="w-full mx-auto px-4" onSubmit={handleSubmit}>
|
||
<div className="flex w-full items-center gap-2 rounded-2xl bg-[#2a2a2a] px-3 py-2 text-white shadow-md">
|
||
<input
|
||
ref={inputRef}
|
||
autoFocus
|
||
value={input}
|
||
disabled={loading}
|
||
placeholder={translate('::AI.Asking')}
|
||
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)}
|
||
/>
|
||
|
||
<div className="shrink-0 text-sm">
|
||
<Dropdown
|
||
placement="top-end"
|
||
menuClass="w-48 max-w-[calc(100vw-2rem)]"
|
||
toggleClassName="[&_svg]:ml-2"
|
||
title={selectedBot?.name || translate('::AI.SelectModel')}
|
||
>
|
||
{bots.map((item) => (
|
||
<Dropdown.Item
|
||
key={item.id}
|
||
eventKey={item.id}
|
||
onSelect={(eventKey: string) => setSelectedBotId(eventKey)}
|
||
>
|
||
{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 || !input.trim() || !selectedBotId}
|
||
className="bg-white !text-gray-800 shadow-sm hover:bg-gray-100"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</Container>
|
||
)
|
||
}
|
||
|
||
export default Assistant
|