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

298 lines
9.9 KiB
TypeScript
Raw Normal View History

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'
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 = () => {
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)
const requestRef = useRef<AbortController | null>(null)
2026-02-24 20:44:16 +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(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [messages, loading])
2026-06-20 09:09:37 +00:00
useEffect(() => {
if (!loading) inputRef.current?.focus()
}, [loading])
2026-06-20 09:09:37 +00:00
const focusInput = () => requestAnimationFrame(() => inputRef.current?.focus())
2026-06-20 09:09:37 +00:00
const handleNewConversation = useCallback(() => {
2026-06-20 09:09:37 +00:00
if (loading) return
startNewConversation()
2026-06-20 09:09:37 +00:00
setInput('')
setIsConversationDrawerOpen(false)
focusInput()
}, [loading, startNewConversation])
2026-06-20 09:09:37 +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
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
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
2026-02-24 20:44:16 +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',
content: question,
createdAt: new Date().toISOString(),
2026-06-20 09:09:37 +00:00
})
2026-02-24 20:44:16 +00:00
const controller = new AbortController()
requestRef.current = controller
2026-03-21 16:59:22 +00:00
try {
if (!selectedBot?.apiUrl?.trim()) {
throw new Error('AI bot url is not configured.')
2026-03-21 16:59:22 +00:00
}
const content = await aiService.askBot(
selectedBot.apiUrl,
{
2026-06-20 09:09:37 +00:00
biletId: conversationId,
question,
2026-06-20 09:09:37 +00:00
sessionId: conversationId,
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',
content,
createdAt: new Date().toISOString(),
2026-06-20 09:09:37 +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',
content: selectedBot ? translate('::AI.RequestFailed') : translate('::AI.SelectModel'),
createdAt: new Date().toISOString(),
2026-06-20 09:09:37 +00:00
})
} finally {
if (requestRef.current === controller) {
requestRef.current = null
setLoading(false)
2026-02-24 20:44:16 +00:00
}
}
}
2026-02-24 20:44:16 +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-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"
onClose={() => setIsConversationDrawerOpen(false)}
onRequestClose={() => setIsConversationDrawerOpen(false)}
2026-06-20 09:09:37 +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">
<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">
<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">
<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}
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-06-20 09:09:37 +00:00
<ScrollBar
autoHide
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>
)}
{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
{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
<div ref={bottomRef} />
2026-02-24 20:44:16 +00:00
</div>
2026-06-20 09:09:37 +00:00
</ScrollBar>
<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
value={input}
disabled={loading}
2026-06-20 09:09:37 +00:00
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)}
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"
title={selectedBot?.name || translate('::AI.SelectModel')}
2026-06-20 09:09:37 +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 />}
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