diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 4d77bfc..487fc47 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -11430,6 +11430,18 @@ "tr": "Yeni Sorgu", "en": "New Query" }, + { + "resourceName": "Platform", + "key": "App.Platform.NewChat", + "tr": "Yeni Sohbet", + "en": "New Chat" + }, + { + "resourceName": "Platform", + "key": "App.Platform.Chats", + "tr": "Sohbetler", + "en": "Chats" + }, { "resourceName": "Platform", "key": "App.Platform.CopySelectedObjects", diff --git a/ui/src/views/ai/Assistant.tsx b/ui/src/views/ai/Assistant.tsx index 60fb3f6..7eb17bf 100644 --- a/ui/src/views/ai/Assistant.tsx +++ b/ui/src/views/ai/Assistant.tsx @@ -1,8 +1,7 @@ -import React, { useState, useRef, useEffect, SyntheticEvent } from 'react' -import { FaRobot } from 'react-icons/fa' -import { useStoreActions, useStoreState } from '@/store' -import { Avatar, Dropdown } from '@/components/ui' -import LoadAiPostsFromLocalStorage from './LoadAiPostsFromLocalStorage' +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 { AiDto } from '@/proxy/ai/models' @@ -32,26 +31,98 @@ interface Message { createdAt?: string } +interface Conversation { + id: string + title: string + messages: Message[] + createdAt: string + updatedAt: string +} + +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 {part.slice(2, -2)} + } + + if (part.startsWith('"') && part.endsWith('"')) { + return ( + + {part.slice(1, -1)} + + ) + } + + return {part} + }) + +const renderFormattedText = (value: string) => { + const text = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\r\n/g, '\n') + + return ( +
+ {text.split('\n').map((line, index) => { + if (!line.trim()) return
+ + const listMarker = line.match(/^[-*+]\s+/) + const isListItem = Boolean(listMarker) + const content = isListItem ? line.slice(listMarker![0].length) : line + + return isListItem ? ( +
+ + {renderInlineText(content)} +
+ ) : ( +

{renderInlineText(content)}

+ ) + })} +
+ ) +} + // Main Component const Assistant = () => { // Hooks - const { addAiPost } = useStoreActions((actions) => actions.admin.messages) - const [messages, setMessages] = useState([]) + const [conversations, setConversations] = useState([]) + const [activeConversationId, setActiveConversationId] = useState(null) + const [isConversationsLoaded, setIsConversationsLoaded] = useState(false) + const [isConversationDrawerOpen, setIsConversationDrawerOpen] = useState(false) + const [, setMessages] = useState([]) + const aiPosts: Message[] = [] const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [bot, setBot] = useState([]) const [selectedBot, setSelectedBot] = useState(null) - const { id, avatar } = useStoreState((state) => state.auth.user) + const { avatar } = useStoreState((state) => state.auth.user) const inputRef = useRef(null) const bottomRef = useRef(null) const { translate } = useLocalization() - const aiPosts = useStoreState((state) => state.admin.messages.aiPosts) + 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) { @@ -59,6 +130,132 @@ const Assistant = () => { } }, [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 () => { @@ -102,20 +299,20 @@ const Assistant = () => { // Gönderme işlemi const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!input.trim()) return + 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 - addAiPost({ role: 'user', content: userMessage, createdAt: userMessageCreatedAt }) - - setMessages((prev) => [ - ...prev, - { role: 'user', content: userMessage, createdAt: userMessageCreatedAt }, - ]) + appendMessage(conversationId, { + role: 'user', + content: userMessage, + createdAt: userMessageCreatedAt, + }) try { const selectedBotItem = bot.find((item) => item.id === selectedBot) @@ -129,9 +326,9 @@ const Assistant = () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - biletId: crypto.randomUUID(), + biletId: conversationId, question: userMessage, - sessionId: id, + sessionId: conversationId, }), }) @@ -144,29 +341,21 @@ const Assistant = () => { } // 2️⃣ Cevabı store'a ekle - const formattedAnswer = - typeof mapped.answer === 'string' ? mapped.answer : JSON.stringify(mapped.answer) - const assistantMessageCreatedAt = new Date().toISOString() - addAiPost({ role: 'assistant', content: mapped, createdAt: assistantMessageCreatedAt }) // mapped bir BaseContent - - setMessages((prev) => [ - ...prev, - { role: 'assistant', content: mapped, createdAt: assistantMessageCreatedAt }, - ]) + 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() - addAiPost({ role: 'assistant', content: errorMessage, createdAt: assistantMessageCreatedAt }) - setMessages((prev) => [ - ...prev, - { - role: 'assistant', - content: errorMessage, - createdAt: assistantMessageCreatedAt, - }, - ]) + appendMessage(conversationId, { + role: 'assistant', + content: errorMessage, + createdAt: assistantMessageCreatedAt, + }) } setLoading(false) @@ -180,9 +369,9 @@ const Assistant = () => { } const typeStyles: Record = { - chat: 'bg-blue-100 text-blue-700', - query: 'bg-green-100 text-green-700', - analyze: 'bg-yellow-100 text-yellow-800', + chat: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200', + query: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-200', + analyze: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-200', } const typeLabels: Record = { @@ -203,13 +392,13 @@ const Assistant = () => { return (
{typeLabels[type] || type}
{sql && ( -
+
                 {cleanedSql}
               
@@ -217,21 +406,24 @@ const Assistant = () => { )} {error ? ( -
+
⚠️ Hata: {error}
) : typeof answer === 'string' ? ( -
{answer}
+ renderFormattedText(answer) ) : Array.isArray(answer) ? ( answer.length === 0 ? (
Sonuç bulunamadı.
) : ( -
+
- + {Object.keys(answer[0]).map((col) => ( - ))} @@ -239,7 +431,7 @@ const Assistant = () => { {answer.map((row, rowIndex) => ( - + {Object.keys(row).map((col, colIndex) => { const val = row[col] const display = @@ -254,7 +446,10 @@ const Assistant = () => { : String(val) return ( - ) @@ -266,9 +461,9 @@ const Assistant = () => { ) ) : typeof answer === 'object' && answer !== null && (answer as any).message ? ( -
{String((answer as any).message)}
+ renderFormattedText(String((answer as any).message)) ) : ( -
+            
               {JSON.stringify(answer, null, 2)}
             
)} @@ -278,7 +473,7 @@ const Assistant = () => { Grafik )} @@ -286,7 +481,11 @@ const Assistant = () => { ) } - return
{String(message.content)}
+ return message.role === 'assistant' ? ( +
{renderFormattedText(String(message.content))}
+ ) : ( +
{String(message.content)}
+ ) } // Render @@ -298,110 +497,236 @@ const Assistant = () => { defaultTitle={APP_NAME} > - -
-
- {messages.length === 0 && ( -
- -

{translate('::AI.Welcome')}

-

{translate('::AI.Name')}

-
- )} - - {messages.map((msg, idx) => ( -
-
- {msg.role === 'user' ? ( - - ) : ( - - )} -
-
-
- {renderMessageContent(msg)} - {msg.createdAt && ( -
- {dayjs(msg.createdAt).format('DD.MM.YYYY HH:mm')} -
- )} -
-
-
- ))} - - {loading && ( -
-
- {translate('::AI.WaitAnswer')} -
- )} - -
-
- -
-
-
+ {col}
+ {display}