sozsoft-platform/ui/src/views/ai/Assistant.tsx
2026-08-11 16:45:08 +03:00

297 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { 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