diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index a7184777..36808030 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -6558,6 +6558,30 @@ "en": "Analysis and Evaluation", "tr": "Analiz & Değerlendirme" }, + { + "resourceName": "Platform", + "key": "AI.ShowSql", + "en": "Show the generated SQL query ({count} rows)", + "tr": "Oluşturulan SQL sorgusunu göster ({count} kayıt)" + }, + { + "resourceName": "Platform", + "key": "AI.RequestFailed", + "en": "Sorry, an error occurred. Please try again.", + "tr": "Üzgünüm, bir hata oluştu. Lütfen tekrar deneyin." + }, + { + "resourceName": "Platform", + "key": "AI.DeleteChat", + "en": "Delete chat", + "tr": "Sohbeti sil" + }, + { + "resourceName": "Platform", + "key": "AI.Chart", + "en": "Chart", + "tr": "Grafik" + }, { "resourceName": "Platform", "key": "blog.posts.title", diff --git a/ui/src/components/template/AiAssistant.tsx b/ui/src/components/template/AiAssistant.tsx index 47bd77a7..0d6f2f2d 100644 --- a/ui/src/components/template/AiAssistant.tsx +++ b/ui/src/components/template/AiAssistant.tsx @@ -20,8 +20,8 @@ const AiAssistant = () => { return (
navigate(ROUTES_ENUM.protected.admin.ai)} className="flex items-center justify-center text-2xl m-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors duration-200" + onClick={() => navigate(ROUTES_ENUM.protected.admin.ai)} >
diff --git a/ui/src/proxy/ai/models.ts b/ui/src/proxy/ai/models.ts index 56007402..ff8a2275 100644 --- a/ui/src/proxy/ai/models.ts +++ b/ui/src/proxy/ai/models.ts @@ -8,15 +8,23 @@ export interface AiBotDto extends FullAuditedEntityDto { } // Types -export type ChatType = 'chat' | 'sql_result' +// Bot tarafı yeni tipler dönebildiği için bilinen değerler dışına da izin verilir. +export type ChatType = 'chat' | 'sql_result' | 'analysis' | (string & {}) export type SqlResultRow = Record +export interface AiBotRequest { + biletId: string + question: string + sessionId: string + tenantName: string | null +} + export interface BaseContent { type: ChatType question: string sql?: string | null - answer?: string | any[] | null + answer?: string | SqlResultRow[] | Record | null columns?: string[] rows?: SqlResultRow[] rowCount?: number diff --git a/ui/src/services/ai.service.ts b/ui/src/services/ai.service.ts index 108167a7..71bd715e 100644 --- a/ui/src/services/ai.service.ts +++ b/ui/src/services/ai.service.ts @@ -1,7 +1,9 @@ -import { AiBotDto } from '@/proxy/ai/models' +import { AiBotDto, AiBotRequest, BaseContent, ChatType } from '@/proxy/ai/models' import { PagedAndSortedResultRequestDto, PagedResultDto } from '../proxy' import apiService, { Config } from './api.service' +const AI_BOT_TIMEOUT_MS = 120_000 + export class AiService { apiName = 'Default' @@ -18,6 +20,57 @@ export class AiService { }, { apiName: this.apiName, ...config }, ) + + /** + * Bot kaydında tanımlı harici uç noktaya soru gönderir. + * Yanıt şeması bota göre değişebildiği için `BaseContent` içine normalize edilir. + */ + askBot = async ( + apiUrl: string, + request: AiBotRequest, + signal?: AbortSignal, + ): Promise => { + const targetUrl = apiUrl.trim() + if (!targetUrl) { + throw new Error('AI bot url is not configured.') + } + + const timeoutController = new AbortController() + const timeoutId = setTimeout(() => timeoutController.abort(), AI_BOT_TIMEOUT_MS) + const abortExternally = () => timeoutController.abort() + signal?.addEventListener('abort', abortExternally) + + try { + const response = await fetch(targetUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal: timeoutController.signal, + }) + + if (!response.ok) { + throw new Error(`AI bot request failed: ${response.status}`) + } + + const data = await response.json() + const raw = (Array.isArray(data) ? data[0] : data) ?? {} + + return { + type: (raw.type as ChatType) || 'chat', + question: raw.question || request.question, + sql: raw.sql ?? null, + answer: raw.answer ?? null, + columns: Array.isArray(raw.columns) ? raw.columns : undefined, + rows: Array.isArray(raw.rows) ? raw.rows : undefined, + rowCount: typeof raw.rowCount === 'number' ? raw.rowCount : undefined, + chart: raw.chart, + error: raw.error ?? null, + } + } finally { + clearTimeout(timeoutId) + signal?.removeEventListener('abort', abortExternally) + } + } } export const aiService = new AiService() diff --git a/ui/src/views/ai/Assistant.tsx b/ui/src/views/ai/Assistant.tsx index d839086f..90a99a87 100644 --- a/ui/src/views/ai/Assistant.tsx +++ b/ui/src/views/ai/Assistant.tsx @@ -1,635 +1,179 @@ -import React, { useState, useRef, useEffect } from 'react' -import { FaComment, FaPaperPlane, FaPlus, FaRobot, FaTrash } from 'react-icons/fa' -import { useStoreActions, useStoreState } from '@/store' -import { Avatar, Button, Drawer, Dropdown, ScrollBar } from '@/components/ui' +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 { - AiBotDto, - BaseContent, - Conversation, - Message, - MessageContent, - SqlResultRow, -} from '@/proxy/ai/models' -import { Container, Loading } from '@/components/shared' -import { Helmet } from 'react-helmet' import { APP_NAME } from '@/constants/app.constant' -import dayjs from 'dayjs' 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 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 storedAiConversations = useStoreState((state) => state.admin.aiConversations) - const { setAiConversations } = useStoreActions((actions) => actions.admin) - const [conversations, setConversations] = useState(storedAiConversations.items) - const [activeConversationId, setActiveConversationId] = useState( - storedAiConversations.aiActiveConversationId, - ) - const [isConversationsLoaded, setIsConversationsLoaded] = useState(false) + 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 [bot, setBot] = useState([]) - const [selectedBot, setSelectedBot] = useState(null) - const { user, tenant } = useStoreState((state) => state.auth) const inputRef = useRef(null) const bottomRef = useRef(null) + const requestRef = useRef(null) - const { translate } = useLocalization() - - const createConversation = (): Conversation => { - const now = new Date().toISOString() - return { - id: crypto.randomUUID(), - title: translate('::App.Platform.NewChat'), - messages: [], - createdAt: now, - updatedAt: now, - } - } + // Açık istek varken sayfadan çıkılırsa iptal et. + useEffect(() => () => requestRef.current?.abort(), []) useEffect(() => { - if (conversations.length === 0) { - const conversation = createConversation() - setConversations([conversation]) - setActiveConversationId(conversation.id) - } else if (!conversations.some((conversation) => conversation.id === activeConversationId)) { - setActiveConversationId(conversations[0].id) - } - setIsConversationsLoaded(true) - }, []) + bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) + }, [messages, loading]) - useEffect(() => { - if (!isConversationsLoaded) return - - setAiConversations({ - items: conversations, - aiActiveConversationId: activeConversationId, - }) - }, [activeConversationId, conversations, isConversationsLoaded, setAiConversations]) - - 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 () => { - try { - const result = await aiService.getList({ - skipCount: 0, - maxResultCount: 1000, - sorting: 'name', - }) - - if (!result?.data?.items) { - console.warn('Bot listesi boş veya hatalı formatta:', result) - setBot([]) - return - } - - setBot(result?.data?.items) - const firstBotId = result?.data?.items?.find((item) => item.id)?.id ?? null - setSelectedBot(firstBotId) - } catch (error) { - console.error('Bot listesi alınırken hata oluştu:', error) - } - } - - fetchBots() - }, []) - - // Scroll to bottom - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) - - // Input focus after loading useEffect(() => { if (!loading) inputRef.current?.focus() }, [loading]) - // Bot seçim işlemi - const onBotItemClick = (eventKey: string) => setSelectedBot(eventKey) + const focusInput = () => requestAnimationFrame(() => inputRef.current?.focus()) - // Gönderme işlemi - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!input.trim() || !activeConversation) return + 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 userMessage = input.trim() const conversationId = activeConversation.id - const userMessageCreatedAt = new Date().toISOString() setInput('') setLoading(true) - // 1️⃣ Soruyu store'a ekle appendMessage(conversationId, { role: 'user', - content: userMessage, - createdAt: userMessageCreatedAt, + content: question, + createdAt: new Date().toISOString(), }) + const controller = new AbortController() + requestRef.current = controller + try { - const selectedBotItem = bot.find((item) => item.id === selectedBot) - const targetUrl = (selectedBotItem?.apiUrl || '').trim() - - if (!targetUrl) { - throw new Error('AI bot URL bulunamadı.') + if (!selectedBot?.apiUrl?.trim()) { + throw new Error('AI bot url is not configured.') } - const response = await fetch(targetUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const content = await aiService.askBot( + selectedBot.apiUrl, + { biletId: conversationId, - question: userMessage, + question, sessionId: conversationId, - tenantName: tenant.tenantName ?? null, - }), - }) - - if (!response.ok) { - throw new Error(`AI bot request failed: ${response.status}`) - } - - const data = await response.json() - const raw = Array.isArray(data) ? data[0] : data - - const mapped: BaseContent = { - type: raw?.type || 'chat', - question: raw?.question || userMessage, - sql: raw?.sql ?? null, - answer: raw?.answer ?? null, - columns: Array.isArray(raw?.columns) ? raw.columns : undefined, - rows: Array.isArray(raw?.rows) ? raw.rows : undefined, - rowCount: typeof raw?.rowCount === 'number' ? raw.rowCount : undefined, - chart: raw?.chart, - error: raw?.error ?? null, - } - - // 2️⃣ Cevabı store'a ekle - const assistantMessageCreatedAt = new Date().toISOString() - - 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() - appendMessage(conversationId, { - role: 'assistant', - content: errorMessage, - createdAt: assistantMessageCreatedAt, - }) - } - - setLoading(false) - } - - // Mesaj içeriği render - const renderMessageContent = (message: Message) => { - if (message.role === 'assistant' && isContentObject(message.content)) { - const { type, sql, answer, chart, error, columns, rows, rowCount } = - message.content as BaseContent & { - error?: string - } - const isSqlResult = type === 'sql_result' - const resultRows: SqlResultRow[] = isSqlResult - ? Array.isArray(rows) - ? rows - : [] - : Array.isArray(answer) - ? answer - : [] - const resultColumns = - Array.isArray(columns) && columns.length > 0 - ? columns - : resultRows.length > 0 - ? Array.from(new Set(resultRows.flatMap((row) => Object.keys(row)))) - : [] - - const typeStyles: Record = { - chat: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200', - sql_result: 'bg-gray-100 text-gray-700 dark:bg-gray-900/50 dark:text-gray-200', - } - - const typeLabels: Record = { - chat: '🗨️ ' + translate('::AI.SohbetAnswer'), - sql_result: `📊 ${translate('::AI.DatabaseAnswer')}`, - } - - const cleanedSql = (() => { - try { - const rawSql = decodeURIComponent(sql || '') - return rawSql.replace(/^```sql\n?/, '').replace(/```$/, '') - } catch { - return sql - } - })() - - return ( -
-
- {typeLabels[type] || type} -
- - {sql && !isSqlResult && ( -
-
-                {cleanedSql}
-              
-
- )} - - {error && ( -
- ⚠️ Hata: {error} -
- )} - - {!error && - (isSqlResult ? ( -
- {sql && ( -
- - - Oluşturulan SQL sorgusunu göster ({rowCount ?? resultRows.length} kayıt) - - - + - - -
-                      {cleanedSql}
-                    
-
- )} - - {resultRows.length === 0 ? ( -
- Sonuç bulunamadı. -
- ) : ( -
- - - - {resultColumns.map((column) => ( - - ))} - - - - {resultRows.map((row, rowIndex) => ( - - {resultColumns.map((column) => { - const value = row[column] - const display = - value === null || value === undefined - ? '—' - : typeof value === 'boolean' - ? value - ? 'Evet' - : 'Hayır' - : typeof value === 'object' - ? JSON.stringify(value) - : String(value) - - return ( - - ) - })} - - ))} - -
- {column} -
- {display} -
-
- )} -
- ) : typeof answer === 'string' ? ( - renderFormattedText(answer) - ) : Array.isArray(answer) ? ( - answer.length === 0 ? ( -
Sonuç bulunamadı.
- ) : ( -
- - - - {Object.keys(answer[0]).map((col) => ( - - ))} - - - - {answer.map((row, rowIndex) => ( - - {Object.keys(row).map((col, colIndex) => { - const val = row[col] - const display = - val === null || val === undefined - ? '—' - : typeof val === 'boolean' - ? val - ? 'Evet' - : 'Hayır' - : typeof val === 'string' && val.endsWith('T00:00:00.000Z') - ? new Date(val).toLocaleDateString('tr-TR') - : String(val) - - return ( - - ) - })} - - ))} - -
- {col} -
- {display} -
-
- ) - ) : typeof answer === 'object' && answer !== null && (answer as any).message ? ( - renderFormattedText(String((answer as any).message)) - ) : answer === null || answer === undefined ? ( -
Sonuç bulunamadı.
- ) : ( -
-                {JSON.stringify(answer, null, 2)}
-              
- ))} - - {chart && ( -
- Grafik -
- )} -
+ tenantName: tenant?.tenantName ?? null, + }, + controller.signal, ) - } - return message.role === 'assistant' ? ( -
{renderFormattedText(String(message.content))}
- ) : ( -
{String(message.content)}
- ) + 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, } - // Render return ( + /> setIsConversationDrawerOpen(false)} - onRequestClose={() => setIsConversationDrawerOpen(false)} placement="left" title={translate('::App.Platform.Chats')} width={320} bodyClass="p-3" + onClose={() => setIsConversationDrawerOpen(false)} + onRequestClose={() => setIsConversationDrawerOpen(false)} > -
-
- {conversations.map((conversation) => ( - - ))} -
+
@@ -640,8 +184,8 @@ const Assistant = () => { className="flex-1" size="sm" icon={} - onClick={handleNewConversation} disabled={loading} + onClick={handleNewConversation} > {translate('::App.Platform.NewChat')} @@ -654,11 +198,12 @@ const Assistant = () => { {translate('::App.Platform.Chats')}
+ (
@@ -680,58 +225,35 @@ const Assistant = () => {
)} - {messages.map((msg, idx) => ( -
-
- {msg.role === 'user' ? ( - - ) : ( - - )} -
-
-
- {renderMessageContent(msg)} - {msg.createdAt && ( -
- {dayjs(msg.createdAt).format('DD.MM.YYYY HH:mm')} -
- )} -
-
-
+ {messages.map((message, index) => ( + ))} - + {loading && ( +
+ + {translate('::AI.WaitAnswer')} +
+ )} -
+
-
+
setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSubmit(e) - } - }} + 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" + 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)} />
@@ -739,18 +261,17 @@ const Assistant = () => { placement="top-end" menuClass="w-48 max-w-[calc(100vw-2rem)]" toggleClassName="[&_svg]:ml-2" - title={ - bot.find((item) => item.id === selectedBot)?.name || - translate('::AI.SelectModel') - } + title={selectedBot?.name || translate('::AI.SelectModel')} > - {bot - .filter((item) => !!item.id) - .map((item) => ( - - {item.name} - - ))} + {bots.map((item) => ( + setSelectedBotId(eventKey)} + > + {item.name} + + ))}
@@ -761,7 +282,7 @@ const Assistant = () => { variant="plain" type="submit" icon={} - disabled={loading} + disabled={loading || !input.trim() || !selectedBotId} className="bg-white !text-gray-800 shadow-sm hover:bg-gray-100" />
diff --git a/ui/src/views/ai/components/AssistantContent.tsx b/ui/src/views/ai/components/AssistantContent.tsx new file mode 100644 index 00000000..b485fed8 --- /dev/null +++ b/ui/src/views/ai/components/AssistantContent.tsx @@ -0,0 +1,107 @@ +import { memo, useMemo } from 'react' +import { BaseContent } from '@/proxy/ai/models' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { cleanSql, toSqlResultRows } from '../utils' +import FormattedText from './FormattedText' +import ResultTable from './ResultTable' + +const TYPE_STYLES: Record = { + chat: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200', + sql_result: 'bg-gray-100 text-gray-700 dark:bg-gray-900/50 dark:text-gray-200', + analysis: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-200', +} + +const TYPE_LABEL_KEYS: Record = { + chat: { icon: '🗨️', key: '::AI.SohbetAnswer' }, + sql_result: { icon: '📊', key: '::AI.DatabaseAnswer' }, + analysis: { icon: '🧠', key: '::AI.AnalizAnswer' }, +} + +const FALLBACK_TYPE_STYLE = 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' + +/** Yapılandırılmış bot yanıtını (sohbet / SQL sonucu / grafik) render eder. */ +const AssistantContent = ({ content }: { content: BaseContent }) => { + const { translate } = useLocalization() + const { type, sql, answer, chart, error, columns, rows, rowCount } = content + + const isSqlResult = type === 'sql_result' + const resultRows = useMemo( + () => toSqlResultRows(isSqlResult ? rows : answer), + [isSqlResult, rows, answer], + ) + const hasTable = isSqlResult || Array.isArray(answer) + const formattedSql = useMemo(() => cleanSql(sql), [sql]) + + const typeLabel = TYPE_LABEL_KEYS[type] + const answerMessage = + !hasTable && typeof answer === 'object' && answer !== null + ? (answer as { message?: unknown }).message + : undefined + + return ( +
+
+ {typeLabel ? `${typeLabel.icon} ${translate(typeLabel.key)}` : type} +
+ + {formattedSql && !isSqlResult && ( +
+          {formattedSql}
+        
+ )} + + {error ? ( +
+ ⚠️ {translate('::App.Platform.Error')}: {error} +
+ ) : hasTable ? ( +
+ {formattedSql && isSqlResult && ( +
+ + + {translate('::AI.ShowSql', { count: rowCount ?? resultRows.length })} + + + + + + +
+                {formattedSql}
+              
+
+ )} + +
+ ) : typeof answer === 'string' && answer.trim() ? ( + + ) : answerMessage ? ( + + ) : answer === null || answer === undefined || answer === '' ? ( +
+ {translate('::App.Platform.NoResults')} +
+ ) : ( +
+          {JSON.stringify(answer, null, 2)}
+        
+ )} + + {chart && ( +
+ {translate('::AI.Chart')} +
+ )} +
+ ) +} + +export default memo(AssistantContent) diff --git a/ui/src/views/ai/components/ConversationList.tsx b/ui/src/views/ai/components/ConversationList.tsx new file mode 100644 index 00000000..a818b262 --- /dev/null +++ b/ui/src/views/ai/components/ConversationList.tsx @@ -0,0 +1,64 @@ +import { memo } from 'react' +import { FaComment, FaTrash } from 'react-icons/fa' +import { Conversation } from '@/proxy/ai/models' +import { useLocalization } from '@/utils/hooks/useLocalization' + +interface ConversationListProps { + conversations: Conversation[] + activeConversationId: string | null + disabled?: boolean + onSelect: (conversationId: string) => void + onDelete: (conversationId: string) => void +} + +const ConversationList = ({ + conversations, + activeConversationId, + disabled, + onSelect, + onDelete, +}: ConversationListProps) => { + const { translate } = useLocalization() + const deleteLabel = translate('::AI.DeleteChat') + + return ( +
+ {conversations.map((conversation) => { + const isActive = conversation.id === activeConversationId + + return ( +
+ + +
+ ) + })} +
+ ) +} + +export default memo(ConversationList) diff --git a/ui/src/views/ai/components/FormattedText.tsx b/ui/src/views/ai/components/FormattedText.tsx new file mode 100644 index 00000000..e28f47a8 --- /dev/null +++ b/ui/src/views/ai/components/FormattedText.tsx @@ -0,0 +1,50 @@ +import React, { memo } from 'react' + +const INLINE_PATTERN = /(\*\*[^*]+\*\*|"[^"\n]+")/g +const LIST_MARKER_PATTERN = /^[-*+]\s+/ + +const renderInlineText = (text: string) => + text.split(INLINE_PATTERN).map((part, index) => { + if (part.startsWith('**') && part.endsWith('**')) { + return {part.slice(2, -2)} + } + + if (part.length > 1 && part.startsWith('"') && part.endsWith('"')) { + return ( + + {part.slice(1, -1)} + + ) + } + + return {part} + }) + +/** Bot yanıtındaki basit markdown benzeri biçimlendirmeyi (kalın, tırnak, madde) render eder. */ +const FormattedText = ({ value }: { 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 = LIST_MARKER_PATTERN.exec(line) + + return listMarker ? ( +
+ + {renderInlineText(line.slice(listMarker[0].length))} +
+ ) : ( +

{renderInlineText(line)}

+ ) + })} +
+ ) +} + +export default memo(FormattedText) diff --git a/ui/src/views/ai/components/MessageBubble.tsx b/ui/src/views/ai/components/MessageBubble.tsx new file mode 100644 index 00000000..a805a776 --- /dev/null +++ b/ui/src/views/ai/components/MessageBubble.tsx @@ -0,0 +1,59 @@ +import { memo } from 'react' +import { FaRobot } from 'react-icons/fa' +import dayjs from 'dayjs' +import { Avatar } from '@/components/ui' +import { Message } from '@/proxy/ai/models' +import { isStructuredContent } from '../utils' +import AssistantContent from './AssistantContent' +import FormattedText from './FormattedText' + +interface MessageBubbleProps { + message: Message + avatar?: string +} + +const MessageBubble = ({ message, avatar }: MessageBubbleProps) => { + const isUser = message.role === 'user' + + return ( +
+ {isUser ? ( + + ) : ( +
+ +
+ )} +
+
+ {isUser ? ( +
{String(message.content)}
+ ) : isStructuredContent(message.content) ? ( + + ) : ( +
+ +
+ )} + {message.createdAt && ( +
+ {dayjs(message.createdAt).format('DD.MM.YYYY HH:mm')} +
+ )} +
+
+
+ ) +} + +export default memo(MessageBubble) diff --git a/ui/src/views/ai/components/ResultTable.tsx b/ui/src/views/ai/components/ResultTable.tsx new file mode 100644 index 00000000..31f2644f --- /dev/null +++ b/ui/src/views/ai/components/ResultTable.tsx @@ -0,0 +1,67 @@ +import { memo, useMemo } from 'react' +import { SqlResultRow } from '@/proxy/ai/models' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { formatCellValue, getColumns } from '../utils' + +const SCROLLBAR_CLASS = + '[scrollbar-color:#6b7280_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-400 [&::-webkit-scrollbar-thumb:hover]:bg-gray-500 [&::-webkit-scrollbar-track]:bg-transparent dark:[&::-webkit-scrollbar-thumb]:bg-gray-500 dark:[&::-webkit-scrollbar-thumb:hover]:bg-gray-400' + +interface ResultTableProps { + rows: SqlResultRow[] + columns?: string[] +} + +const ResultTable = ({ rows, columns }: ResultTableProps) => { + const { translate } = useLocalization() + const resolvedColumns = useMemo(() => getColumns(rows, columns), [rows, columns]) + const labels = useMemo( + () => ({ yes: translate('::App.Platform.Yes'), no: translate('::App.Platform.No') }), + [translate], + ) + + if (rows.length === 0 || resolvedColumns.length === 0) { + return ( +
+ {translate('::App.Platform.NoResults')} +
+ ) + } + + return ( +
+ + + + {resolvedColumns.map((column) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {resolvedColumns.map((column) => ( + + ))} + + ))} + +
+ {column} +
+ {formatCellValue(row[column], labels)} +
+
+ ) +} + +export default memo(ResultTable) diff --git a/ui/src/views/ai/hooks/useAiBots.ts b/ui/src/views/ai/hooks/useAiBots.ts new file mode 100644 index 00000000..3da4fb34 --- /dev/null +++ b/ui/src/views/ai/hooks/useAiBots.ts @@ -0,0 +1,44 @@ +import { useEffect, useMemo, useState } from 'react' +import { AiBotDto } from '@/proxy/ai/models' +import { aiService } from '@/services/ai.service' + +/** Listede yalnızca id'si olan botlar tutulur; seçim id üzerinden yapılır. */ +export type AiBot = AiBotDto & { id: string } + +/** AI bot listesini bir kez yükler ve seçili botu yönetir. */ +export const useAiBots = () => { + const [bots, setBots] = useState([]) + const [selectedBotId, setSelectedBotId] = useState(null) + + useEffect(() => { + let isMounted = true + + aiService + .getList({ skipCount: 0, maxResultCount: 1000, sorting: 'name' }) + .then((result) => { + if (!isMounted) return + + const items = (result?.data?.items ?? []).filter((item): item is AiBot => !!item?.id) + setBots(items) + setSelectedBotId((current) => + current && items.some((item) => item.id === current) ? current : (items[0]?.id ?? null), + ) + }) + .catch((error) => { + if (!isMounted) return + console.error('AI bot list could not be loaded:', error) + setBots([]) + }) + + return () => { + isMounted = false + } + }, []) + + const selectedBot = useMemo( + () => bots.find((item) => item.id === selectedBotId) ?? null, + [bots, selectedBotId], + ) + + return { bots, selectedBot, selectedBotId, setSelectedBotId } +} diff --git a/ui/src/views/ai/hooks/useConversations.ts b/ui/src/views/ai/hooks/useConversations.ts new file mode 100644 index 00000000..be1750f5 --- /dev/null +++ b/ui/src/views/ai/hooks/useConversations.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Conversation, Message } from '@/proxy/ai/models' +import { useStoreActions, useStoreState } from '@/store' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { createId, getConversationTitle, sortConversations } from '../utils' + +const EMPTY_MESSAGES: Message[] = [] + +interface ConversationsState { + items: Conversation[] + activeId: string +} + +/** Sohbet listesini yönetir ve store ile senkron tutar. */ +export const useConversations = () => { + const storedConversations = useStoreState((state) => state.admin.aiConversations) + const { setAiConversations } = useStoreActions((actions) => actions.admin) + const { translate } = useLocalization() + + const createConversation = useCallback((): Conversation => { + const now = new Date().toISOString() + return { + id: createId(), + title: translate('::App.Platform.NewChat'), + messages: [], + createdAt: now, + updatedAt: now, + } + }, [translate]) + + const [state, setState] = useState(() => { + const items = storedConversations.items.length + ? sortConversations(storedConversations.items) + : [createConversation()] + const storedActiveId = storedConversations.aiActiveConversationId + + return { + items, + activeId: items.some((item) => item.id === storedActiveId) ? storedActiveId! : items[0].id, + } + }) + + useEffect(() => { + setAiConversations({ items: state.items, aiActiveConversationId: state.activeId }) + }, [state, setAiConversations]) + + const activeConversation = useMemo( + () => state.items.find((item) => item.id === state.activeId), + [state], + ) + + const appendMessage = useCallback((conversationId: string, message: Message) => { + setState((previous) => ({ + ...previous, + items: sortConversations( + previous.items.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, + ), + ), + })) + }, []) + + const startNewConversation = useCallback(() => { + setState((previous) => { + // Boş bir sohbet zaten varsa yenisini oluşturmak yerine ona geç. + const emptyConversation = previous.items.find((item) => item.messages.length === 0) + if (emptyConversation) { + return { ...previous, activeId: emptyConversation.id } + } + + const conversation = createConversation() + return { items: [conversation, ...previous.items], activeId: conversation.id } + }) + }, [createConversation]) + + const selectConversation = useCallback((conversationId: string) => { + setState((previous) => ({ ...previous, activeId: conversationId })) + }, []) + + const deleteConversation = useCallback( + (conversationId: string) => { + setState((previous) => { + const remaining = previous.items.filter((item) => item.id !== conversationId) + const items = remaining.length ? remaining : [createConversation()] + + return { + items, + activeId: previous.activeId === conversationId ? items[0].id : previous.activeId, + } + }) + }, + [createConversation], + ) + + return { + conversations: state.items, + activeConversationId: state.activeId, + activeConversation, + messages: activeConversation?.messages ?? EMPTY_MESSAGES, + appendMessage, + startNewConversation, + selectConversation, + deleteConversation, + } +} diff --git a/ui/src/views/ai/utils.ts b/ui/src/views/ai/utils.ts new file mode 100644 index 00000000..6b738bb5 --- /dev/null +++ b/ui/src/views/ai/utils.ts @@ -0,0 +1,75 @@ +import dayjs from 'dayjs' +import { BaseContent, Conversation, MessageContent, SqlResultRow } from '@/proxy/ai/models' + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/ + +/** `crypto.randomUUID` yalnızca secure context'te vardır; http ortamları için yedeği var. */ +export const createId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` +} + +export const getConversationTitle = (message: string) => + message.length > 42 ? `${message.slice(0, 42).trimEnd()}…` : message + +export const isStructuredContent = (content: MessageContent): content is BaseContent => + typeof content === 'object' && content !== null && 'type' in content + +export const sortConversations = (conversations: Conversation[]) => + [...conversations].sort((first, second) => Date.parse(second.updatedAt) - Date.parse(first.updatedAt)) + +export const cleanSql = (sql?: string | null): string => { + if (!sql) return '' + + let value = sql + try { + value = decodeURIComponent(sql) + } catch { + // Zaten decode edilmiş ya da geçersiz escape dizisi; ham değeri kullan. + } + + return value + .replace(/^```(?:sql)?\n?/i, '') + .replace(/```$/, '') + .trim() +} + +/** Satırların tümünü tarayarak kolon listesi üretir (satırlar farklı alanlar içerebilir). */ +export const getColumns = (rows: SqlResultRow[], columns?: string[]): string[] => { + if (Array.isArray(columns) && columns.length > 0) return columns + + const unique = new Set() + rows.forEach((row) => { + if (row && typeof row === 'object') { + Object.keys(row).forEach((key) => unique.add(key)) + } + }) + + return [...unique] +} + +export const formatCellValue = ( + value: unknown, + labels: { yes: string; no: string }, +): string => { + if (value === null || value === undefined || value === '') return '—' + if (typeof value === 'boolean') return value ? labels.yes : labels.no + if (typeof value === 'string') { + if (!ISO_DATE_PATTERN.test(value)) return value + + const date = dayjs(value) + if (!date.isValid()) return value + + // Saat bilgisi taşımayan değerleri gereksiz "00:00" ile göstermemek için. + return date.format(date.hour() || date.minute() || date.second() ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY') + } + if (typeof value === 'object') return JSON.stringify(value) + + return String(value) +} + +export const toSqlResultRows = (value: unknown): SqlResultRow[] => + Array.isArray(value) ? (value.filter((row) => row && typeof row === 'object') as SqlResultRow[]) : []