mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
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:
committed by
decolua
parent
01e4a28f0a
commit
6b0cced884
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,7 +16,7 @@
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
product
|
||||
# production
|
||||
/build
|
||||
|
||||
|
||||
962
src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js
Normal file
962
src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js
Normal 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 có 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>
|
||||
);
|
||||
}
|
||||
5
src/app/(dashboard)/dashboard/basic-chat/page.js
Normal file
5
src/app/(dashboard)/dashboard/basic-chat/page.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import BasicChatPageClient from "./BasicChatPageClient";
|
||||
|
||||
export default function BasicChatPage() {
|
||||
return <BasicChatPageClient />;
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user