diff --git a/.gitignore b/.gitignore index 75f91bd..7f2e17a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ # next.js /.next/ /out/ - +product # production /build diff --git a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js new file mode 100644 index 0000000..fb1fc4c --- /dev/null +++ b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js @@ -0,0 +1,962 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Badge, Button } from "@/shared/components"; +import { getModelsByProviderId } from "@/shared/constants/models"; +import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider } from "@/shared/constants/providers"; + +const STORAGE_KEYS = { + sessions: "basic-chat.sessions", + activeSessionId: "basic-chat.activeSessionId", + activeProviderId: "basic-chat.activeProviderId", + draft: "basic-chat.draft", +}; + +function createId() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + return `chat_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +function safeParse(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function textValue(value) { + if (typeof value === "string") return value; + if (value == null) return ""; + if (Array.isArray(value)) return value.map(textValue).filter(Boolean).join(" "); + if (typeof value === "object") { + if (typeof value.message === "string") return value.message; + if (typeof value.error === "string") return value.error; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +function humanize(value = "") { + return String(value) + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) + .trim() || "Unknown"; +} + +function formatRelativeTime(value) { + if (!value) return "Now"; + const time = new Date(value).getTime(); + if (Number.isNaN(time)) return "Now"; + const diffMinutes = Math.max(1, Math.round((Date.now() - time) / 60000)); + if (diffMinutes < 60) return `${diffMinutes}m`; + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h`; + return `${Math.round(diffHours / 24)}d`; +} + +function makeSessionTitle(text = "") { + const normalized = textValue(text).replace(/\s+/g, " ").trim(); + if (!normalized) return "New chat"; + return normalized.length > 52 ? `${normalized.slice(0, 52).trimEnd()}…` : normalized; +} + +function buildUserContent(message) { + const text = textValue(message.content).trim(); + const attachments = Array.isArray(message.attachments) ? message.attachments : []; + + if (attachments.length === 0) return text; + + const content = []; + if (text) content.push({ type: "text", text }); + + for (const attachment of attachments) { + if (attachment?.dataUrl) { + content.push({ type: "image_url", image_url: { url: attachment.dataUrl } }); + } + } + + return content.length > 0 ? content : text; +} + +function readAssistantText(chunk) { + if (!chunk || typeof chunk !== "object") return ""; + const choice = chunk.choices?.[0]; + const delta = choice?.delta || {}; + const pieces = [delta.content, choice?.message?.content, chunk.output_text, chunk.text] + .map(textValue) + .filter(Boolean); + return pieces[0] || ""; +} + +async function fileToDataUrl(file) { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("Failed to read file")); + reader.readAsDataURL(file); + }); +} + +function cloneSession(session) { + return { + ...session, + messages: Array.isArray(session.messages) ? session.messages.map((message) => ({ ...message })) : [], + }; +} + +function getProviderLabel(connection) { + return connection?.name || humanize(connection?.provider || connection?.id || "provider"); +} + +function normalizeStaticModel(model, connection) { + if (!model?.id) return null; + return { + id: `${connection.provider}/${model.id}`, + requestModel: `${connection.provider}/${model.id}`, + name: model.name || model.id, + providerId: connection.provider, + providerName: getProviderLabel(connection), + source: "static", + }; +} + +function normalizeLiveModel(model, connection) { + const rawId = typeof model === "string" ? model : model?.id || model?.name || model?.model || ""; + if (!rawId) return null; + + const displayName = typeof model === "string" + ? model + : model?.name || model?.displayName || rawId; + + let requestModel = rawId; + const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider); + if (isCompatible && !rawId.includes("/")) { + requestModel = `${connection.provider}/${rawId}`; + } + + return { + id: requestModel, + requestModel, + name: displayName, + providerId: connection.provider, + providerName: getProviderLabel(connection), + source: "live", + }; +} + +function parseProviderModelsPayload(data) { + if (Array.isArray(data?.models)) return data.models; + if (Array.isArray(data?.data)) return data.data; + if (Array.isArray(data?.results)) return data.results; + if (Array.isArray(data)) return data; + return []; +} + +function dedupeModels(models) { + const map = new Map(); + for (const model of models) { + if (!model?.id) continue; + if (!map.has(model.id)) map.set(model.id, model); + } + return Array.from(map.values()); +} + +export default function BasicChatPageClient() { + const [providerGroups, setProviderGroups] = useState([]); + const [loadingData, setLoadingData] = useState(true); + const [loadError, setLoadError] = useState(""); + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(""); + const [activeProviderId, setActiveProviderId] = useState(""); + const [activeModelId, setActiveModelId] = useState(""); + const [draft, setDraft] = useState(""); + const [attachments, setAttachments] = useState([]); + const [isSending, setIsSending] = useState(false); + const [streamingMessageId, setStreamingMessageId] = useState(""); + const [streamingText, setStreamingText] = useState(""); + const [isHydrated, setIsHydrated] = useState(false); + const [modelMenuOpen, setModelMenuOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const fileInputRef = useRef(null); + const abortRef = useRef(null); + const initializedRef = useRef(false); + const modelMenuRef = useRef(null); + const historyMenuRef = useRef(null); + + useEffect(() => { + try { + const savedSessions = safeParse(globalThis.localStorage.getItem(STORAGE_KEYS.sessions), []); + setSessions(Array.isArray(savedSessions) ? savedSessions.map((session) => ({ + ...session, + messages: Array.isArray(session.messages) ? session.messages : [], + })) : []); + setActiveSessionId(globalThis.localStorage.getItem(STORAGE_KEYS.activeSessionId) || ""); + setActiveProviderId(globalThis.localStorage.getItem(STORAGE_KEYS.activeProviderId) || ""); + setDraft(globalThis.localStorage.getItem(STORAGE_KEYS.draft) || ""); + } catch { + // Ignore storage errors. + } finally { + setIsHydrated(true); + } + }, []); + + useEffect(() => { + let cancelled = false; + + async function loadData() { + setLoadingData(true); + setLoadError(""); + + try { + const providersRes = await fetch("/api/providers", { cache: "no-store" }); + const providersData = await providersRes.json().catch(() => ({})); + const connections = Array.isArray(providersData.connections) + ? providersData.connections.filter((connection) => connection?.isActive !== false) + : []; + + if (connections.length === 0) { + if (!cancelled) { + setProviderGroups([]); + setLoadError("Chưa có provider nào được connect."); + } + return; + } + + const providerMap = new Map(); + + for (const connection of connections) { + const providerId = connection.provider || connection.id; + const providerName = getProviderLabel(connection); + const providerType = isOpenAICompatibleProvider(providerId) + ? "openai-compatible" + : isAnthropicCompatibleProvider(providerId) + ? "anthropic-compatible" + : providerId; + + if (!providerMap.has(providerId)) { + providerMap.set(providerId, { + providerId, + providerName, + providerType, + connections: [], + models: [], + }); + } + + const group = providerMap.get(providerId); + group.providerName = group.providerName || providerName; + group.providerType = group.providerType || providerType; + group.connections.push(connection); + + const staticModels = getModelsByProviderId(providerId) + .map((model) => normalizeStaticModel(model, connection)) + .filter(Boolean); + group.models.push(...staticModels); + } + + const liveResults = await Promise.all( + connections.map(async (connection) => { + try { + const response = await fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" }); + const data = await response.json().catch(() => ({})); + if (!response.ok) return { connection, models: [] }; + const models = parseProviderModelsPayload(data) + .map((model) => normalizeLiveModel(model, connection)) + .filter(Boolean); + return { connection, models }; + } catch { + return { connection, models: [] }; + } + }) + ); + + for (const result of liveResults) { + const providerId = result.connection.provider || result.connection.id; + const group = providerMap.get(providerId); + if (!group) continue; + group.models.push(...result.models); + } + + const normalized = Array.from(providerMap.values()) + .map((group) => ({ + ...group, + models: dedupeModels(group.models).sort((a, b) => a.name.localeCompare(b.name)), + })) + .filter((group) => group.models.length > 0) + .sort((a, b) => a.providerName.localeCompare(b.providerName)); + + if (!cancelled) { + setProviderGroups(normalized); + if (normalized.length === 0) { + setLoadError("Đã có provider connect nhưng chưa lấy được model nào."); + } + } + } catch (error) { + if (!cancelled) { + setLoadError(textValue(error?.message) || "Không thể tải danh sách provider/model."); + setProviderGroups([]); + } + } finally { + if (!cancelled) setLoadingData(false); + } + } + + loadData(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const handleClickOutside = (event) => { + if (modelMenuRef.current && !modelMenuRef.current.contains(event.target)) { + setModelMenuOpen(false); + } + if (historyMenuRef.current && !historyMenuRef.current.contains(event.target)) { + setHistoryOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const modelIndex = useMemo(() => { + const map = new Map(); + for (const group of providerGroups) { + for (const model of group.models) { + map.set(model.id, { + ...model, + providerId: group.providerId, + providerName: group.providerName, + }); + } + } + return map; + }, [providerGroups]); + + const activeProviderGroup = useMemo(() => { + return providerGroups.find((group) => group.providerId === activeProviderId) || providerGroups[0] || null; + }, [providerGroups, activeProviderId]); + + const activeModel = useMemo(() => { + if (activeModelId && modelIndex.has(activeModelId)) return modelIndex.get(activeModelId); + if (activeSessionId) { + const session = sessions.find((item) => item.id === activeSessionId); + if (session?.modelId && modelIndex.has(session.modelId)) return modelIndex.get(session.modelId); + } + return activeProviderGroup?.models?.[0] || null; + }, [activeModelId, modelIndex, activeProviderGroup, sessions, activeSessionId]); + + const currentSession = useMemo(() => sessions.find((session) => session.id === activeSessionId) || null, [sessions, activeSessionId]); + const currentMessages = currentSession?.messages || []; + const sessionItems = useMemo(() => [...sessions].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), [sessions]); + const canSend = !isSending && !!activeModel && (draft.trim().length > 0 || attachments.length > 0); + + useEffect(() => { + if (!isHydrated) return; + try { + globalThis.localStorage.setItem(STORAGE_KEYS.sessions, JSON.stringify(sessions)); + globalThis.localStorage.setItem(STORAGE_KEYS.activeSessionId, activeSessionId); + globalThis.localStorage.setItem(STORAGE_KEYS.activeProviderId, activeProviderId); + globalThis.localStorage.setItem(STORAGE_KEYS.draft, draft); + } catch { + // Ignore storage errors. + } + }, [isHydrated, sessions, activeSessionId, activeProviderId, draft]); + + useEffect(() => { + if (!isHydrated || loadingData || initializedRef.current) return; + if (providerGroups.length === 0) return; + + const savedProvider = providerGroups.find((group) => group.providerId === activeProviderId) || providerGroups[0]; + const savedModel = activeModelId && modelIndex.has(activeModelId) + ? modelIndex.get(activeModelId) + : savedProvider.models[0]; + + if (sessions.length > 0) { + const session = sessions.find((item) => item.id === activeSessionId) || sessions[0]; + const sessionModel = session?.modelId && modelIndex.has(session.modelId) + ? modelIndex.get(session.modelId) + : savedModel; + initializedRef.current = true; + setActiveSessionId(session.id); + setActiveProviderId(sessionModel?.providerId || savedProvider.providerId); + setActiveModelId(sessionModel?.id || savedModel.id); + return; + } + + const session = { + id: createId(), + title: "New chat", + providerId: savedProvider.providerId, + providerName: savedProvider.providerName, + modelId: savedModel.id, + modelName: savedModel.name, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messages: [], + }; + + initializedRef.current = true; + setSessions([session]); + setActiveSessionId(session.id); + setActiveProviderId(savedProvider.providerId); + setActiveModelId(savedModel.id); + }, [isHydrated, loadingData, providerGroups, modelIndex, sessions, activeSessionId, activeProviderId, activeModelId]); + + const updateSession = (sessionId, updater) => { + setSessions((prev) => prev.map((session) => (session.id === sessionId ? updater(cloneSession(session)) : session))); + }; + + const ensureSessionForModel = (model) => { + if (!model) return null; + return { + id: createId(), + title: "New chat", + providerId: model.providerId, + providerName: model.providerName, + modelId: model.id, + modelName: model.name, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messages: [], + }; + }; + + const handleNewChat = () => { + if (!activeModel) return; + const session = ensureSessionForModel(activeModel); + if (!session) return; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(session.id); + setActiveProviderId(session.providerId); + setActiveModelId(session.modelId); + setDraft(""); + setAttachments([]); + setStreamingMessageId(""); + setStreamingText(""); + }; + + const handleSelectSession = (sessionId) => { + const session = sessions.find((item) => item.id === sessionId); + if (!session) return; + setActiveSessionId(sessionId); + setActiveProviderId(session.providerId || activeProviderId); + setActiveModelId(session.modelId || activeModelId); + setHistoryOpen(false); + }; + + const handleDeleteCurrentChat = () => { + if (!activeSessionId) return; + const nextSessions = sessions.filter((session) => session.id !== activeSessionId); + const fallback = nextSessions[0] || null; + setSessions(nextSessions); + if (fallback) { + setActiveSessionId(fallback.id); + setActiveProviderId(fallback.providerId); + setActiveModelId(fallback.modelId); + } else { + setActiveSessionId(""); + setActiveProviderId(""); + setActiveModelId(""); + } + }; + + const handleSelectProvider = (providerId) => { + const group = providerGroups.find((item) => item.providerId === providerId); + if (!group || group.models.length === 0) return; + const nextModel = group.models[0]; + + const current = sessions.find((session) => session.id === activeSessionId); + if (current && current.messages.length > 0) { + const session = ensureSessionForModel(nextModel); + if (!session) return; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(session.id); + } else if (current) { + setSessions((prev) => prev.map((item) => (item.id === current.id ? { + ...item, + providerId: group.providerId, + providerName: group.providerName, + modelId: nextModel.id, + modelName: nextModel.name, + } : item))); + setActiveSessionId(current.id); + } + + setActiveProviderId(group.providerId); + setActiveModelId(nextModel.id); + setModelMenuOpen(false); + }; + + const handleSelectModel = (modelId) => { + const model = modelIndex.get(modelId); + if (!model) return; + + const current = sessions.find((session) => session.id === activeSessionId); + if (current && current.messages.length > 0) { + const session = ensureSessionForModel(model); + if (!session) return; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(session.id); + } else if (current) { + setSessions((prev) => prev.map((item) => (item.id === current.id ? { + ...item, + providerId: model.providerId, + providerName: model.providerName, + modelId: model.id, + modelName: model.name, + } : item))); + setActiveSessionId(current.id); + } else { + const session = ensureSessionForModel(model); + if (!session) return; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(session.id); + } + + setActiveProviderId(model.providerId); + setActiveModelId(model.id); + setModelMenuOpen(false); + }; + + const handleAttachFiles = async (event) => { + const files = Array.from(event.target.files || []); + if (files.length === 0) return; + + const images = files.filter((file) => file.type.startsWith("image/")); + if (images.length === 0) { + event.target.value = ""; + return; + } + + const converted = await Promise.all(images.map(async (file) => ({ + id: createId(), + name: file.name, + type: file.type, + size: file.size, + dataUrl: await fileToDataUrl(file), + }))); + + setAttachments((prev) => [...prev, ...converted]); + event.target.value = ""; + }; + + const removeAttachment = (attachmentId) => { + setAttachments((prev) => prev.filter((attachment) => attachment.id !== attachmentId)); + }; + + const handleStop = () => { + abortRef.current?.abort(); + }; + + const finalizeSessionTitle = (sessionId, titleSeed) => { + const title = makeSessionTitle(titleSeed); + updateSession(sessionId, (session) => ({ + ...session, + title: session.title === "New chat" ? title : session.title, + updatedAt: new Date().toISOString(), + })); + }; + + const sendMessage = async () => { + const model = activeModel || activeProviderGroup?.models?.[0] || null; + if (!model) return; + + const userText = draft.trim(); + if (!userText && attachments.length === 0) return; + + let sessionId = activeSessionId; + let session = sessions.find((item) => item.id === sessionId); + if (!session) { + session = ensureSessionForModel(model); + if (!session) return; + sessionId = session.id; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(sessionId); + } + + const userMessage = { + id: createId(), + role: "user", + content: userText, + attachments: attachments.map((attachment) => ({ + id: attachment.id, + name: attachment.name, + type: attachment.type, + dataUrl: attachment.dataUrl, + })), + createdAt: new Date().toISOString(), + }; + + const assistantMessageId = createId(); + const assistantMessage = { + id: assistantMessageId, + role: "assistant", + content: "", + createdAt: new Date().toISOString(), + status: "streaming", + }; + + const nextMessages = [...(session.messages || []), userMessage, assistantMessage]; + setSessions((prev) => prev.map((item) => (item.id === sessionId ? { + ...item, + providerId: model.providerId, + providerName: model.providerName, + modelId: model.id, + modelName: model.name, + messages: nextMessages, + updatedAt: new Date().toISOString(), + title: item.title === "New chat" ? makeSessionTitle(userText) : item.title, + } : item))); + setDraft(""); + setAttachments([]); + setIsSending(true); + setStreamingMessageId(assistantMessageId); + setStreamingText(""); + abortRef.current?.abort(); + abortRef.current = new AbortController(); + + const requestMessages = nextMessages + .filter((message) => !(message.role === "assistant" && message.id === assistantMessageId)) + .map((message) => ({ + role: message.role, + content: message.role === "user" ? buildUserContent(message) : message.content, + })); + + try { + const response = await fetch("/api/dashboard/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ + model: model.requestModel || model.id, + messages: requestMessages, + stream: true, + }), + signal: abortRef.current.signal, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(textValue(errorData.error || errorData.message || `Request failed (${response.status})`)); + } + + const reader = response.body?.getReader(); + if (!reader) { + const data = await response.json().catch(() => ({})); + const fallbackText = textValue(data?.choices?.[0]?.message?.content || data?.output_text || data?.error || data?.message || ""); + updateSession(sessionId, (currentSession) => ({ + ...currentSession, + messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: fallbackText, status: "done" } : message)), + updatedAt: new Date().toISOString(), + })); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + let assistantText = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + + try { + const chunk = JSON.parse(payload); + const text = readAssistantText(chunk); + if (!text) continue; + + assistantText += text; + setStreamingText(assistantText); + updateSession(sessionId, (currentSession) => ({ + ...currentSession, + messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: assistantText, status: "streaming" } : message)), + updatedAt: new Date().toISOString(), + })); + } catch { + // Ignore malformed chunks. + } + } + } + + updateSession(sessionId, (currentSession) => ({ + ...currentSession, + messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: assistantText || message.content, status: "done" } : message)), + updatedAt: new Date().toISOString(), + })); + finalizeSessionTitle(sessionId, userText); + } catch (error) { + if (error.name !== "AbortError") { + const errorText = textValue(error?.message || error); + updateSession(sessionId, (currentSession) => ({ + ...currentSession, + messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: message.content || `Error: ${errorText}`, status: "error" } : message)), + updatedAt: new Date().toISOString(), + })); + setLoadError(errorText || "Không thể gửi tin nhắn."); + } + } finally { + setIsSending(false); + setStreamingMessageId(""); + setStreamingText(""); + abortRef.current = null; + } + }; + + const handleKeyDown = (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + if (canSend) sendMessage(); + } + }; + + const modelLabel = activeModel ? `${activeModel.name}` : "Select model"; + const modelSubLabel = activeModel ? activeModel.requestModel : "Choose from connected providers"; + + return ( +
Models
+Chỉ lấy từ provider đã connect
+{group.providerName}
+Recent chats
+{loadError}
++ Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting! +
++ Model list is filtered from connected providers. +
+