Claude güncellemesi AiBot güncellemesi
This commit is contained in:
parent
de7e6c1e21
commit
a5ee6de819
13 changed files with 814 additions and 627 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ const AiAssistant = () => {
|
|||
return (
|
||||
<Tooltip title={translate('::' + AI_ASSISTANT)}>
|
||||
<div
|
||||
onClick={() => 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)}
|
||||
>
|
||||
<FcHeadset size={24} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,15 +8,23 @@ export interface AiBotDto extends FullAuditedEntityDto<string> {
|
|||
}
|
||||
|
||||
// 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<string, unknown>
|
||||
|
||||
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<string, unknown> | null
|
||||
columns?: string[]
|
||||
rows?: SqlResultRow[]
|
||||
rowCount?: number
|
||||
|
|
|
|||
|
|
@ -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<BaseContent> => {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 <strong key={index}>{part.slice(2, -2)}</strong>
|
||||
}
|
||||
|
||||
if (part.startsWith('"') && part.endsWith('"')) {
|
||||
return (
|
||||
<q
|
||||
key={index}
|
||||
className="rounded-md border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-medium text-blue-800 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-200"
|
||||
>
|
||||
{part.slice(1, -1)}
|
||||
</q>
|
||||
)
|
||||
}
|
||||
|
||||
return <React.Fragment key={index}>{part}</React.Fragment>
|
||||
})
|
||||
|
||||
const renderFormattedText = (value: string) => {
|
||||
const text = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\r\n/g, '\n')
|
||||
|
||||
return (
|
||||
<div className="space-y-2 whitespace-pre-wrap leading-6">
|
||||
{text.split('\n').map((line, index) => {
|
||||
if (!line.trim()) return <div key={index} className="h-2" />
|
||||
|
||||
const listMarker = line.match(/^[-*+]\s+/)
|
||||
const isListItem = Boolean(listMarker)
|
||||
const content = isListItem ? line.slice(listMarker![0].length) : line
|
||||
|
||||
return isListItem ? (
|
||||
<div key={index} className="flex gap-2">
|
||||
<span className="text-blue-500 dark:text-blue-300">•</span>
|
||||
<span>{renderInlineText(content)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p key={index}>{renderInlineText(content)}</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main Component
|
||||
const Assistant = () => {
|
||||
// Hooks
|
||||
const storedAiConversations = useStoreState((state) => state.admin.aiConversations)
|
||||
const { setAiConversations } = useStoreActions((actions) => actions.admin)
|
||||
const [conversations, setConversations] = useState<Conversation[]>(storedAiConversations.items)
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(
|
||||
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<AiBotDto[]>([])
|
||||
const [selectedBot, setSelectedBot] = useState<string | null>(null)
|
||||
|
||||
const { user, tenant } = useStoreState((state) => state.auth)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const requestRef = useRef<AbortController | null>(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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div
|
||||
className={`inline-block px-3 py-1 text-xs font-medium rounded-full mb-2 ${typeStyles[type] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}`}
|
||||
>
|
||||
{typeLabels[type] || type}
|
||||
</div>
|
||||
|
||||
{sql && !isSqlResult && (
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded border border-gray-200 dark:border-gray-700 text-xs font-mono whitespace-pre-wrap">
|
||||
<pre>
|
||||
<code>{cleanedSql}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-700 dark:text-red-200 bg-red-100 dark:bg-red-900/50 border border-red-300 dark:border-red-800 rounded p-2">
|
||||
⚠️ <strong>Hata:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error &&
|
||||
(isSqlResult ? (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-900/70 dark:bg-gray-800">
|
||||
{sql && (
|
||||
<details className="group border-b border-gray-100 px-4 py-2.5 dark:border-gray-700">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-xs font-medium text-gray-500 marker:content-none hover:text-emerald-700 dark:text-gray-400 dark:hover:text-emerald-300">
|
||||
<span>
|
||||
Oluşturulan SQL sorgusunu göster ({rowCount ?? resultRows.length} kayıt)
|
||||
</span>
|
||||
<span className="text-base leading-none transition-transform group-open:rotate-45">
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<pre className="mt-3 max-h-48 overflow-auto rounded-lg bg-gray-950 p-3 text-xs leading-5 text-emerald-100 [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-500 [&::-webkit-scrollbar-thumb:hover]:bg-gray-400 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
<code>{cleanedSql}</code>
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{resultRows.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-gray-500 italic">
|
||||
Sonuç bulunamadı.
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[400px] overflow-auto pb-1 [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:[scrollbar-color:#6b7280_transparent] dark:[&::-webkit-scrollbar-thumb]:bg-gray-500 dark:[&::-webkit-scrollbar-thumb:hover]:bg-gray-400">
|
||||
<table className="w-full min-w-max text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-gray-800 text-xs tracking-wide text-gray-100 shadow-sm dark:bg-gray-950">
|
||||
<tr>
|
||||
{resultColumns.map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
className="whitespace-nowrap border-b border-r border-indigo-400/20 px-4 py-3 text-left font-semibold last:border-r-0"
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resultRows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className="border-b border-gray-100 even:bg-slate-50/80 hover:bg-emerald-50/70 dark:border-gray-700 dark:even:bg-gray-800/60 dark:hover:bg-emerald-950/20"
|
||||
>
|
||||
{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 (
|
||||
<td
|
||||
key={column}
|
||||
className="whitespace-nowrap border-r border-gray-100 px-2 py-2 align-top text-gray-700 first:font-medium first:text-slate-900 last:border-r-0 dark:border-gray-700 dark:text-gray-200 dark:first:text-white"
|
||||
>
|
||||
{display}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : typeof answer === 'string' ? (
|
||||
renderFormattedText(answer)
|
||||
) : Array.isArray(answer) ? (
|
||||
answer.length === 0 ? (
|
||||
<div className="text-gray-500 italic">Sonuç bulunamadı.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded">
|
||||
<table className="table-auto w-full text-sm">
|
||||
<thead className="bg-gray-100 dark:bg-gray-800 sticky top-0">
|
||||
<tr>
|
||||
{Object.keys(answer[0]).map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="border border-gray-200 dark:border-gray-700 px-2 py-1 text-left"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{answer.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{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 (
|
||||
<td
|
||||
key={colIndex}
|
||||
className="border border-gray-200 dark:border-gray-700 px-2 py-1"
|
||||
>
|
||||
{display}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
) : typeof answer === 'object' && answer !== null && (answer as any).message ? (
|
||||
renderFormattedText(String((answer as any).message))
|
||||
) : answer === null || answer === undefined ? (
|
||||
<div className="text-gray-500 italic">Sonuç bulunamadı.</div>
|
||||
) : (
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded border border-gray-200 dark:border-gray-700">
|
||||
{JSON.stringify(answer, null, 2)}
|
||||
</pre>
|
||||
))}
|
||||
|
||||
{chart && (
|
||||
<div className="mt-4">
|
||||
<img
|
||||
src={`data:image/png;base64,${chart}`}
|
||||
alt="Grafik"
|
||||
className="max-w-full rounded border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
tenantName: tenant?.tenantName ?? null,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
}
|
||||
|
||||
return message.role === 'assistant' ? (
|
||||
<div className="text-sm">{renderFormattedText(String(message.content))}</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-sm">{String(message.content)}</div>
|
||||
)
|
||||
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 (
|
||||
<Container>
|
||||
<Helmet
|
||||
titleTemplate={`%s | ${APP_NAME}`}
|
||||
title={translate('::' + AI_ASSISTANT)}
|
||||
defaultTitle={APP_NAME}
|
||||
></Helmet>
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
isOpen={isConversationDrawerOpen}
|
||||
onClose={() => setIsConversationDrawerOpen(false)}
|
||||
onRequestClose={() => setIsConversationDrawerOpen(false)}
|
||||
placement="left"
|
||||
title={translate('::App.Platform.Chats')}
|
||||
width={320}
|
||||
bodyClass="p-3"
|
||||
onClose={() => setIsConversationDrawerOpen(false)}
|
||||
onRequestClose={() => setIsConversationDrawerOpen(false)}
|
||||
>
|
||||
<Button block icon={<FaPlus />} onClick={handleNewConversation} disabled={loading}>
|
||||
<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">
|
||||
<div className="space-y-1">
|
||||
{conversations.map((conversation) => (
|
||||
<Button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
variant="plain"
|
||||
shape="none"
|
||||
onClick={() => handleSelectConversation(conversation.id)}
|
||||
className={`!inline-flex !h-auto !w-full items-center gap-2 rounded-lg !px-3 py-3 text-left text-sm transition-colors ${
|
||||
conversation.id === activeConversationId
|
||||
? '!bg-blue-50 text-blue-700 dark:!bg-blue-900/40 dark:text-blue-200'
|
||||
: '!bg-transparent text-gray-700 hover:!bg-gray-100 dark:text-gray-200 dark:hover:!bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FaComment className="shrink-0" />
|
||||
<span className="truncate">{conversation.title}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${conversation.title} sohbetini sil`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleDeleteConversation(conversation.id)
|
||||
}}
|
||||
className="ml-auto shrink-0 rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600 dark:text-gray-500 dark:hover:bg-red-900/40 dark:hover:text-red-300"
|
||||
>
|
||||
<FaTrash className="text-xs" />
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<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 />} onClick={handleNewConversation} disabled={loading}>
|
||||
<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="space-y-1 pr-2">
|
||||
{conversations.map((conversation) => (
|
||||
<Button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
variant="plain"
|
||||
shape="none"
|
||||
onClick={() => handleSelectConversation(conversation.id)}
|
||||
className={`!inline-flex !h-auto !w-full items-center gap-2 rounded-lg !px-3 py-2 text-left text-sm transition-colors ${
|
||||
conversation.id === activeConversationId
|
||||
? '!bg-blue-50 text-blue-700 dark:!bg-blue-900/40 dark:text-blue-200'
|
||||
: '!bg-transparent text-gray-700 hover:!bg-gray-100 dark:text-gray-200 dark:hover:!bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FaComment className="shrink-0" />
|
||||
<span className="truncate">{conversation.title}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${conversation.title} sohbetini sil`}
|
||||
title="Sohbeti sil"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleDeleteConversation(conversation.id)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleDeleteConversation(conversation.id)
|
||||
}
|
||||
}}
|
||||
className="ml-auto shrink-0 rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600 dark:text-gray-500 dark:hover:bg-red-900/40 dark:hover:text-red-300"
|
||||
>
|
||||
<FaTrash className="text-xs" />
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
<div className="pr-2">
|
||||
<ConversationList {...conversationListProps} />
|
||||
</div>
|
||||
</ScrollBar>
|
||||
</aside>
|
||||
|
|
@ -640,8 +184,8 @@ const Assistant = () => {
|
|||
className="flex-1"
|
||||
size="sm"
|
||||
icon={<FaPlus />}
|
||||
onClick={handleNewConversation}
|
||||
disabled={loading}
|
||||
onClick={handleNewConversation}
|
||||
>
|
||||
{translate('::App.Platform.NewChat')}
|
||||
</Button>
|
||||
|
|
@ -654,11 +198,12 @@ const Assistant = () => {
|
|||
{translate('::App.Platform.Chats')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollBar
|
||||
autoHide
|
||||
hideTracksWhenNotNeeded
|
||||
autoHideTimeout={700}
|
||||
autoHideDuration={200}
|
||||
hideTracksWhenNotNeeded
|
||||
className="flex-1 min-h-0 min-w-0"
|
||||
renderView={({ style, ...props }) => (
|
||||
<div {...props} style={{ ...style, overflowX: 'hidden' }} />
|
||||
|
|
@ -680,58 +225,35 @@ const Assistant = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-start gap-2.5 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center ${msg.role === 'user' ? 'bg-blue-500' : 'bg-gray-600'}`}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
<Avatar size={32} shape="circle" src={user.avatar} alt="avatar" />
|
||||
) : (
|
||||
<FaRobot className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[90%] min-w-0 rounded-lg p-2 ${msg.role === 'user' ? 'bg-blue-500 text-white' : 'bg-white text-gray-800 dark:bg-gray-800 dark:text-gray-100'}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderMessageContent(msg)}
|
||||
{msg.createdAt && (
|
||||
<div
|
||||
className={`text-[11px] leading-none ${msg.role === 'user' ? 'text-white/80 text-right' : 'text-gray-500 dark:text-gray-400 text-right'}`}
|
||||
>
|
||||
{dayjs(msg.createdAt).format('DD.MM.YYYY HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{messages.map((message, index) => (
|
||||
<MessageBubble
|
||||
key={`${activeConversationId}-${index}`}
|
||||
message={message}
|
||||
avatar={user?.avatar}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Loading type="default" loading={loading}></Loading>
|
||||
{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>
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</ScrollBar>
|
||||
|
||||
<form onSubmit={handleSubmit} className="w-full mx-auto px-4">
|
||||
<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}
|
||||
value={input}
|
||||
autoFocus
|
||||
onChange={(e) => 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)}
|
||||
/>
|
||||
|
||||
<div className="shrink-0 text-sm">
|
||||
|
|
@ -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) => (
|
||||
<Dropdown.Item key={item.id!} eventKey={item.id!} onSelect={onBotItemClick}>
|
||||
{item.name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
{bots.map((item) => (
|
||||
<Dropdown.Item
|
||||
key={item.id}
|
||||
eventKey={item.id}
|
||||
onSelect={(eventKey: string) => setSelectedBotId(eventKey)}
|
||||
>
|
||||
{item.name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
|
|
@ -761,7 +282,7 @@ const Assistant = () => {
|
|||
variant="plain"
|
||||
type="submit"
|
||||
icon={<FaPaperPlane />}
|
||||
disabled={loading}
|
||||
disabled={loading || !input.trim() || !selectedBotId}
|
||||
className="bg-white !text-gray-800 shadow-sm hover:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
107
ui/src/views/ai/components/AssistantContent.tsx
Normal file
107
ui/src/views/ai/components/AssistantContent.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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<string, { icon: string; key: string }> = {
|
||||
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 (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div
|
||||
className={`inline-block px-3 py-1 text-xs font-medium rounded-full mb-2 ${
|
||||
TYPE_STYLES[type] || FALLBACK_TYPE_STYLE
|
||||
}`}
|
||||
>
|
||||
{typeLabel ? `${typeLabel.icon} ${translate(typeLabel.key)}` : type}
|
||||
</div>
|
||||
|
||||
{formattedSql && !isSqlResult && (
|
||||
<pre className="overflow-x-auto rounded border border-gray-200 bg-gray-100 p-3 text-xs font-mono dark:border-gray-700 dark:bg-gray-800">
|
||||
<code>{formattedSql}</code>
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-red-300 bg-red-100 p-2 text-red-700 dark:border-red-800 dark:bg-red-900/50 dark:text-red-200">
|
||||
⚠️ <strong>{translate('::App.Platform.Error')}:</strong> {error}
|
||||
</div>
|
||||
) : hasTable ? (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-900/70 dark:bg-gray-800">
|
||||
{formattedSql && isSqlResult && (
|
||||
<details className="group border-b border-gray-100 px-4 py-2.5 dark:border-gray-700">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-xs font-medium text-gray-500 marker:content-none hover:text-emerald-700 dark:text-gray-400 dark:hover:text-emerald-300">
|
||||
<span>
|
||||
{translate('::AI.ShowSql', { count: rowCount ?? resultRows.length })}
|
||||
</span>
|
||||
<span className="text-base leading-none transition-transform group-open:rotate-45">
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<pre className="mt-3 max-h-48 overflow-auto rounded-lg bg-gray-950 p-3 text-xs leading-5 text-emerald-100 [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-500 [&::-webkit-scrollbar-thumb:hover]:bg-gray-400 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
<code>{formattedSql}</code>
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<ResultTable rows={resultRows} columns={columns} />
|
||||
</div>
|
||||
) : typeof answer === 'string' && answer.trim() ? (
|
||||
<FormattedText value={answer} />
|
||||
) : answerMessage ? (
|
||||
<FormattedText value={String(answerMessage)} />
|
||||
) : answer === null || answer === undefined || answer === '' ? (
|
||||
<div className="text-gray-500 italic dark:text-gray-400">
|
||||
{translate('::App.Platform.NoResults')}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="overflow-x-auto rounded border border-gray-200 bg-gray-100 p-2 text-xs dark:border-gray-700 dark:bg-gray-800">
|
||||
{JSON.stringify(answer, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{chart && (
|
||||
<div className="mt-4">
|
||||
<img
|
||||
src={`data:image/png;base64,${chart}`}
|
||||
alt={translate('::AI.Chart')}
|
||||
className="max-w-full rounded border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AssistantContent)
|
||||
64
ui/src/views/ai/components/ConversationList.tsx
Normal file
64
ui/src/views/ai/components/ConversationList.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-1">
|
||||
{conversations.map((conversation) => {
|
||||
const isActive = conversation.id === activeConversationId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
className={`flex items-center gap-2 rounded-lg pr-1 transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
>
|
||||
<FaComment className="shrink-0" />
|
||||
<span className="truncate">{conversation.title}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="shrink-0 rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-60 dark:text-gray-500 dark:hover:bg-red-900/40 dark:hover:text-red-300"
|
||||
onClick={() => onDelete(conversation.id)}
|
||||
>
|
||||
<FaTrash className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ConversationList)
|
||||
50
ui/src/views/ai/components/FormattedText.tsx
Normal file
50
ui/src/views/ai/components/FormattedText.tsx
Normal file
|
|
@ -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 <strong key={index}>{part.slice(2, -2)}</strong>
|
||||
}
|
||||
|
||||
if (part.length > 1 && part.startsWith('"') && part.endsWith('"')) {
|
||||
return (
|
||||
<q
|
||||
key={index}
|
||||
className="rounded-md border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-medium text-blue-800 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-200"
|
||||
>
|
||||
{part.slice(1, -1)}
|
||||
</q>
|
||||
)
|
||||
}
|
||||
|
||||
return <React.Fragment key={index}>{part}</React.Fragment>
|
||||
})
|
||||
|
||||
/** 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 (
|
||||
<div className="space-y-2 whitespace-pre-wrap leading-6">
|
||||
{text.split('\n').map((line, index) => {
|
||||
if (!line.trim()) return <div key={index} className="h-2" />
|
||||
|
||||
const listMarker = LIST_MARKER_PATTERN.exec(line)
|
||||
|
||||
return listMarker ? (
|
||||
<div key={index} className="flex gap-2">
|
||||
<span className="text-blue-500 dark:text-blue-300">•</span>
|
||||
<span>{renderInlineText(line.slice(listMarker[0].length))}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p key={index}>{renderInlineText(line)}</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FormattedText)
|
||||
59
ui/src/views/ai/components/MessageBubble.tsx
Normal file
59
ui/src/views/ai/components/MessageBubble.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={`flex items-start gap-2.5 ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
{isUser ? (
|
||||
<Avatar size={32} shape="circle" src={avatar} alt="avatar" />
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600">
|
||||
<FaRobot className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[90%] min-w-0 rounded-lg p-2 ${
|
||||
isUser
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white text-gray-800 dark:bg-gray-800 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap text-sm">{String(message.content)}</div>
|
||||
) : isStructuredContent(message.content) ? (
|
||||
<AssistantContent content={message.content} />
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
<FormattedText value={String(message.content)} />
|
||||
</div>
|
||||
)}
|
||||
{message.createdAt && (
|
||||
<div
|
||||
className={`text-right text-[11px] leading-none ${
|
||||
isUser ? 'text-white/80' : 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{dayjs(message.createdAt).format('DD.MM.YYYY HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MessageBubble)
|
||||
67
ui/src/views/ai/components/ResultTable.tsx
Normal file
67
ui/src/views/ai/components/ResultTable.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="px-4 py-6 text-center text-gray-500 italic dark:text-gray-400">
|
||||
{translate('::App.Platform.NoResults')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`max-h-[400px] overflow-auto pb-1 ${SCROLLBAR_CLASS}`}>
|
||||
<table className="w-full min-w-max text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-gray-800 text-xs tracking-wide text-gray-100 shadow-sm dark:bg-gray-950">
|
||||
<tr>
|
||||
{resolvedColumns.map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
className="whitespace-nowrap border-b border-r border-indigo-400/20 px-4 py-3 text-left font-semibold last:border-r-0"
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className="border-b border-gray-100 even:bg-slate-50/80 hover:bg-emerald-50/70 dark:border-gray-700 dark:even:bg-gray-800/60 dark:hover:bg-emerald-950/20"
|
||||
>
|
||||
{resolvedColumns.map((column) => (
|
||||
<td
|
||||
key={column}
|
||||
className="whitespace-nowrap border-r border-gray-100 px-2 py-2 align-top text-gray-700 first:font-medium first:text-slate-900 last:border-r-0 dark:border-gray-700 dark:text-gray-200 dark:first:text-white"
|
||||
>
|
||||
{formatCellValue(row[column], labels)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ResultTable)
|
||||
44
ui/src/views/ai/hooks/useAiBots.ts
Normal file
44
ui/src/views/ai/hooks/useAiBots.ts
Normal file
|
|
@ -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<AiBot[]>([])
|
||||
const [selectedBotId, setSelectedBotId] = useState<string | null>(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 }
|
||||
}
|
||||
115
ui/src/views/ai/hooks/useConversations.ts
Normal file
115
ui/src/views/ai/hooks/useConversations.ts
Normal file
|
|
@ -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<ConversationsState>(() => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
75
ui/src/views/ai/utils.ts
Normal file
75
ui/src/views/ai/utils.ts
Normal file
|
|
@ -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<string>()
|
||||
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[]) : []
|
||||
Loading…
Reference in a new issue