diff --git a/app/(dashboard)/chat/actions.ts b/app/(dashboard)/chat/actions.ts index 335530c..1f9369c 100644 --- a/app/(dashboard)/chat/actions.ts +++ b/app/(dashboard)/chat/actions.ts @@ -1,14 +1,32 @@ "use server"; +import { getSqliteConnection } from "@/server/db/client"; +import { ContentTranslationService, getContentFallbackLocale } from "@/server/i18n/content"; +import { resolveFreelancerLocale } from "@/server/i18n/resolver"; import { requireFreelancerBackend } from "@/server/web/freelancer"; export async function listChatSessionsAction() { - const { actor, service } = await requireFreelancerBackend(); - return service.listChatSessions(actor).map((session) => ({ - id: session.id, - title: session.title, - created_at: session.createdAt.toISOString(), - })); + const { context, actor, service } = await requireFreelancerBackend(); + const locale = await resolveFreelancerLocale(context); + const content = new ContentTranslationService(getSqliteConnection().db); + const localization = content.getLocalizationContext(actor); + const sessions = service.listChatSessions(actor); + const translations = content.listBatch("chat_session", sessions.map((session) => session.id)); + + return sessions.map((session) => { + const resolved = content.resolveEntity("chat_session", session, { + locale: locale.locale, + fallbackLocale: getContentFallbackLocale(locale.locale, localization), + defaultLocale: localization.defaultLocale, + translations: translations.get(session.id) ?? [], + }); + + return { + id: session.id, + title: resolved.title, + created_at: session.createdAt.toISOString(), + }; + }); } export async function listChatMessagesAction(sessionId: string) { @@ -17,12 +35,18 @@ export async function listChatMessagesAction(sessionId: string) { id: message.id, role: message.role, content: message.content, + source_locale: message.sourceLocale, })); } export async function createChatSessionAction(title: string) { - const { actor, service } = await requireFreelancerBackend(); + const { context, actor, service } = await requireFreelancerBackend(); + const locale = await resolveFreelancerLocale(context); const session = service.createChatSession(actor, { title }); + const content = new ContentTranslationService(getSqliteConnection().db); + content.upsertEntityTranslations("chat_session", session.id, { + [locale.locale]: { title }, + }); return { id: session.id, title: session.title, diff --git a/app/(dashboard)/chat/chat-client.tsx b/app/(dashboard)/chat/chat-client.tsx new file mode 100644 index 0000000..fee0737 --- /dev/null +++ b/app/(dashboard)/chat/chat-client.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { useI18n } from "@/components/i18n/i18n-provider"; +import { DefaultChatTransport, type UIMessage } from "ai"; +import type { Translator } from "@/lib/i18n"; +import { Brain, Loader2, MessageSquare, Plus, Send, Trash2 } from "lucide-react"; +import { Button } from "poyraz-ui/atoms"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "poyraz-ui/molecules"; +import { + createChatSessionAction, + deleteChatSessionAction, + listChatMessagesAction, + listChatSessionsAction, +} from "./actions"; + +function formatMessageContent(text: string) { + if (!text) return null; + const lines = text.split("\n"); + return lines.map((line, i) => ( + + {line.split(/(\*\*.*?\*\*|\*.*?\*)/g).map((part, j) => { + if (part.startsWith("**") && part.endsWith("**")) { + return ( + + {part.slice(2, -2)} + + ); + } + if (part.startsWith("*") && part.endsWith("*")) { + return {part.slice(1, -1)}; + } + return {part}; + })} + {i !== lines.length - 1 &&
} +
+ )); +} + +type ChatSession = { + id: string; + title: string; + created_at: string; +}; + +export function AIChatClient({ locale }: { locale: string }) { + const i18n = useI18n(); + const t = i18n.t; + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [input, setInput] = useState(""); + const [isMobileSessionsOpen, setIsMobileSessionsOpen] = useState(false); + const messagesEndRef = useRef(null); + + const { messages, sendMessage, setMessages, status, stop } = useChat({ + transport: new DefaultChatTransport({ api: "/api/chat" }), + onError: (error) => { + console.error(error); + toast.error(resolveChatError(t, error)); + }, + }); + const isLoading = status === "submitted" || status === "streaming"; + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + useEffect(() => { + async function fetchSessions() { + try { + const data = await listChatSessionsAction(); + setSessions(data); + setActiveSessionId(data[0]?.id || null); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("chat.errors.loadSessions")); + } + } + + void fetchSessions(); + }, [t]); + + useEffect(() => { + async function fetchMessages() { + if (!activeSessionId) { + setMessages([]); + return; + } + + try { + const data = await listChatMessagesAction(activeSessionId); + const formattedMessages: UIMessage[] = data.map((message) => ({ + id: message.id, + role: message.role as UIMessage["role"], + parts: [{ type: "text", text: message.content }], + })); + setMessages(formattedMessages); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("chat.errors.loadMessages")); + } + } + + void fetchMessages(); + }, [activeSessionId, setMessages, t]); + + async function handleNewChat() { + setActiveSessionId(null); + setMessages([]); + } + + async function handleDeleteSession(id: string, event: React.MouseEvent) { + event.stopPropagation(); + try { + await deleteChatSessionAction(id); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("chat.errors.deleteSession")); + return; + } + + const nextSessions = sessions.filter((session) => session.id !== id); + setSessions(nextSessions); + + if (activeSessionId === id) { + setActiveSessionId(nextSessions[0]?.id || null); + if (nextSessions.length === 0) setMessages([]); + } + } + + async function handleSubmit(event: { preventDefault: () => void }) { + event.preventDefault(); + + const currentInput = input.trim(); + if (!currentInput || isLoading) return; + + let sessionId = activeSessionId; + setInput(""); + + if (!sessionId) { + let newSession: ChatSession; + try { + newSession = await createChatSessionAction( + currentInput.length > 32 ? `${currentInput.slice(0, 32)}...` : currentInput, + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("chat.errors.createSession")); + setInput(currentInput); + return; + } + + sessionId = newSession.id; + setActiveSessionId(sessionId); + setSessions((currentSessions) => [newSession, ...currentSessions]); + } + + await sendMessage({ text: currentInput }, { body: { sessionId, sourceLocale: locale } }); + } + + const SessionsSidebarContent = ( + <> +
+

+ + {t("chat.sidebar.title")} +

+ +
+ +
+ {sessions.length === 0 ? ( +
+ {t("chat.sidebar.empty")} +
+ ) : ( + sessions.map((session) => ( +
+ + +
+ )) + )} +
+ + ); + + return ( +
+ + {/* Desktop Sidebar */} + + + {/* Mobile Sidebar Overlay */} + {isMobileSessionsOpen && ( +
setIsMobileSessionsOpen(false)} + /> + )} + + {/* Mobile Sidebar Drawer */} + +
+
+
+
+ +
+
+

{t("chat.title")}

+
+
+ +
+ +
+ {messages.length === 0 ? ( +
+
+ +
+

{t("chat.empty.title")}

+

+ {t("chat.empty.description")} +

+
+ ) : ( + messages.map((message) => { + const text = getMessageText(message); + + return ( +
+
+ {formatMessageContent(text)} +
+
+ ); + }) + )} + + {isLoading ? ( +
+ + {t("chat.messages.loading")} +
+ ) : null} +
+
+ +
+
+