feat(ui): add Basic Chat interface for testing models

Add a simple chat UI to the dashboard for quickly testing AI models from
connected providers. Features include:
- Model picker from all connected providers
- Streaming chat responses
- Image attachment support
- Session history with localStorage persistence
- Responsive design with dark theme

Note: Removed build.sh from original PR as it contained syntax errors and
was unrelated to the chat UI feature.

Co-authored-by: Nguyễn Trung Hiếu <140531897+bonelag@users.noreply.github.com>
Made-with: Cursor
This commit is contained in:
Nguyễn Trung Hiếu
2026-03-23 09:45:04 +07:00
committed by decolua
parent 01e4a28f0a
commit 6b0cced884
5 changed files with 971 additions and 3 deletions

2
.gitignore vendored
View File

@@ -16,7 +16,7 @@
# next.js
/.next/
/out/
product
# production
/build

View File

@@ -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 (
<div className="relative flex-1 flex flex-col h-full min-h-0 min-w-0 bg-[#212121] text-white overflow-hidden">
<div className="relative mx-auto flex flex-1 h-full min-h-0 w-full max-w-4xl flex-col">
<div className="flex shrink-0 items-center justify-between gap-3 px-4 py-3 lg:px-6">
<div ref={modelMenuRef} className="relative">
<button
type="button"
onClick={() => setModelMenuOpen((value) => !value)}
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-left transition hover:bg-white/8"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">{modelLabel}</span>
<span className="material-symbols-outlined text-[18px] text-white/70">expand_more</span>
</div>
<p className="truncate text-xs text-white/55">{modelSubLabel}</p>
</div>
</button>
{modelMenuOpen ? (
<div className="absolute left-0 top-[calc(100%+10px)] z-30 w-[min(520px,calc(100vw-2rem))] overflow-hidden rounded-[20px] border border-white/10 bg-[#262626] shadow-2xl shadow-black/50">
<div className="border-b border-white/10 px-4 py-3">
<p className="text-xs uppercase tracking-[0.22em] text-white/45">Models</p>
<p className="text-sm text-white/75">Chỉ lấy từ provider đã connect</p>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2 custom-scrollbar">
{providerGroups.map((group) => (
<div key={group.providerId} className="mb-2 rounded-[16px] border border-white/10 bg-black/20 p-2">
<div className="flex items-center justify-between px-2 py-2">
<p className="text-sm font-semibold text-white">{group.providerName}</p>
<Badge size="sm" variant="default">{group.models.length}</Badge>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{group.models.map((model) => {
const isActive = model.id === activeModelId;
return (
<button
key={model.id}
type="button"
onClick={() => handleSelectModel(model.id)}
className={`rounded-[14px] border px-3 py-3 text-left transition ${isActive ? "border-blue-400/40 bg-blue-500/15" : "border-white/10 bg-white/5 hover:bg-white/8"}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{model.name}</p>
<p className="truncate text-[11px] text-white/45">{model.requestModel}</p>
</div>
{isActive ? <span className="material-symbols-outlined text-[18px] text-blue-300">check_circle</span> : null}
</div>
</button>
);
})}
</div>
</div>
))}
</div>
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setHistoryOpen((value) => !value)}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/80 transition hover:bg-white/8"
>
History
</button>
<Button variant="ghost" size="sm" icon="delete" onClick={handleDeleteCurrentChat} disabled={!activeSessionId || sessions.length === 0}>
Clear
</Button>
</div>
</div>
{historyOpen ? (
<div ref={historyMenuRef} className="absolute right-4 top-[72px] z-20 w-[min(360px,calc(100vw-2rem))] rounded-[20px] border border-white/10 bg-[#262626] p-2 shadow-2xl shadow-black/50 lg:right-6">
<div className="px-3 py-2">
<p className="text-xs uppercase tracking-[0.22em] text-white/45">Recent chats</p>
</div>
<div className="max-h-[48vh] space-y-2 overflow-y-auto p-1 custom-scrollbar">
{sessionItems.length === 0 ? (
<div className="rounded-[16px] border border-dashed border-white/10 bg-white/5 p-4 text-sm text-white/55">
Chưa cuộc trò chuyện nào.
</div>
) : sessionItems.map((session) => {
const isActive = session.id === activeSessionId;
const latestMessage = [...(session.messages || [])].reverse().find((message) => message.role === "user") || session.messages?.[0];
return (
<button
key={session.id}
type="button"
onClick={() => handleSelectSession(session.id)}
className={`w-full rounded-[16px] border px-3 py-3 text-left transition ${isActive ? "border-blue-400/40 bg-blue-500/15" : "border-white/10 bg-white/5 hover:bg-white/8"}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{session.title}</p>
<p className="mt-1 truncate text-xs text-white/50">{textValue(latestMessage?.content) || "Empty chat"}</p>
</div>
<span className="text-[10px] text-white/40 shrink-0">{formatRelativeTime(session.updatedAt)}</span>
</div>
</button>
);
})}
</div>
</div>
) : null}
{loadError ? (
<div className="mt-4 rounded-[18px] border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-rose-100">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-[20px]">error</span>
<p className="text-sm leading-6">{loadError}</p>
</div>
</div>
) : null}
<div className="flex flex-1 flex-col min-h-0">
<div className="flex-1 overflow-y-auto py-4 custom-scrollbar">
{currentMessages.length === 0 ? (
<div className="flex min-h-[50vh] items-center justify-center px-4 text-center">
<div className="max-w-xl space-y-4">
<div className="mx-auto flex size-16 items-center justify-center rounded-[20px] border border-white/10 bg-white/5 text-white/80">
<span className="material-symbols-outlined text-[30px]">chat</span>
</div>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-white">Start a conversation</h2>
<p className="text-sm leading-6 text-white/60">
Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting!
</p>
</div>
</div>
</div>
) : null}
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4">
{currentMessages.map((message) => {
const isUser = message.role === "user";
const isAssistant = message.role === "assistant";
const isStreaming = isAssistant && message.id === streamingMessageId && message.status === "streaming";
const content = textValue(message.content) || (isAssistant ? streamingText : "");
return (
<div key={message.id} className={`flex w-full ${isUser ? "justify-end" : "justify-start"} mb-6`}>
<div className={`max-w-[min(88%,42rem)] ${isUser ? "rounded-3xl bg-[#2f2f2f] px-5 py-3.5 text-white" : "text-white/90"}`}>
<div className="mb-1 flex items-center justify-between gap-3">
<span className="text-xs font-semibold">{isUser ? "You" : activeModel?.name || "Assistant"}</span>
</div>
{message.attachments?.length ? (
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 mt-2">
{message.attachments.map((attachment) => (
<a key={attachment.id} href={attachment.dataUrl} target="_blank" rel="noreferrer" className="overflow-hidden rounded-[18px] border border-white/10 bg-black/20">
<img src={attachment.dataUrl} alt={attachment.name} className="h-28 w-full object-cover" />
</a>
))}
</div>
) : null}
<div className="whitespace-pre-wrap break-words text-[15px] leading-7">
{content}
{isAssistant && isStreaming && !streamingText ? <span className="inline-block animate-pulse"></span> : null}
</div>
</div>
</div>
);
})}
</div>
</div>
<div className="shrink-0 pt-2">
{attachments.length > 0 ? (
<div className="mx-auto mb-3 flex w-full max-w-3xl flex-wrap gap-2 px-4">
{attachments.map((attachment) => (
<div key={attachment.id} className="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-2">
<span className="text-xs text-white/80 max-w-[12rem] truncate">{attachment.name}</span>
<button type="button" onClick={() => removeAttachment(attachment.id)} className="text-white/55 hover:text-white" aria-label="Remove attachment">
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
))}
</div>
) : null}
<div className="mx-auto w-full max-w-3xl px-4 pb-2">
<div className="rounded-[26px] bg-[#2f2f2f] px-3 pt-3 pb-2 shadow-[0_0_15px_rgba(0,0,0,0.10)] ring-1 ring-white/5">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message AI"
rows={1}
className="w-full resize-none bg-transparent px-2 text-[15px] leading-6 text-white outline-none placeholder:text-white/40 custom-scrollbar max-h-[25vh] overflow-y-auto"
/>
<div className="mt-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={!activeModel || loadingData} className="p-2 text-white/50 hover:text-white transition rounded-full hover:bg-white/5">
<span className="material-symbols-outlined text-[20px]">attach_file</span>
</button>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleAttachFiles} />
<span className="text-xs font-medium text-white/30 truncate max-w-[120px]">{activeModel ? activeModel.name : "No model"}</span>
</div>
<div className="flex items-center gap-2">
{isSending ? (
<button type="button" onClick={handleStop} className="p-2 text-white bg-white/10 hover:bg-white/20 transition rounded-full h-8 w-8 flex items-center justify-center">
<span className="material-symbols-outlined text-[16px]">stop</span>
</button>
) : null}
<button onClick={sendMessage} disabled={!canSend} className={`h-8 w-8 rounded-full flex items-center justify-center transition ${canSend ? 'bg-white text-black hover:opacity-90' : 'bg-white/10 text-white/30 cursor-not-allowed'}`}>
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
</button>
</div>
</div>
</div>
</div>
</div>
<p className="mx-auto mt-2 max-w-3xl px-4 pb-4 text-center text-[11px] text-white/30">
Model list is filtered from connected providers.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import BasicChatPageClient from "./BasicChatPageClient";
export default function BasicChatPage() {
return <BasicChatPageClient />;
}

View File

@@ -13,6 +13,7 @@ const navItems = [
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
{ href: "/dashboard/proxy-pools", label: "Proxy Pools", icon: "lan" },
{ href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" },
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },

View File

@@ -93,8 +93,8 @@ export default function DashboardLayout({ children }) {
{/* Main content */}
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300">
<Header key={pathname} onMenuClick={() => setSidebarOpen(true)} />
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 lg:p-10">
<div className="max-w-7xl mx-auto">{children}</div>
<div className={`flex-1 overflow-y-auto custom-scrollbar ${pathname === "/dashboard/basic-chat" ? "" : "p-6 lg:p-10"} ${pathname === "/dashboard/basic-chat" ? "flex flex-col overflow-hidden" : ""}`}>
<div className={`${pathname === "/dashboard/basic-chat" ? "flex-1 w-full h-full flex flex-col" : "max-w-7xl mx-auto"}`}>{children}</div>
</div>
</main>
</div>