fixed page state while generating a message
This commit is contained in:
@@ -30,3 +30,5 @@ logs/
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.vercel
|
||||||
|
.env*
|
||||||
|
|||||||
+12
-9
@@ -17,23 +17,26 @@ import supabaseAuth, { optionalAuth } from "./middleware/supabaseAuth.js";
|
|||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
const CORS_ORIGINS = (process.env.CORS_ORIGIN || "http://localhost:5173")
|
||||||
|
.split(",")
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
// Trust proxy hop count so req.ip (and therefore rate limiting) sees the real
|
// Trust proxy hop count so req.ip (and therefore rate limiting) sees the real
|
||||||
// client IP behind a reverse proxy. Off by default; set TRUST_PROXY=1 in prod.
|
// client IP behind a reverse proxy. Off by default; set TRUST_PROXY=1 in prod.
|
||||||
if (process.env.TRUST_PROXY) {
|
if (process.env.TRUST_PROXY) {
|
||||||
app.set("trust proxy", process.env.TRUST_PROXY);
|
app.set("trust proxy", process.env.TRUST_PROXY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(securityHeaders);
|
app.use(securityHeaders);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cors({ origin: CORS_ORIGIN, credentials: true }));
|
app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ status: "ok", provider: process.env.AI_PROVIDER });
|
res.json({ status: "ok", provider: process.env.AI_PROVIDER });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth routes — public endpoints (signup, signin, signout, me)
|
// Auth routes — public endpoints (signup, signin, signout, me)
|
||||||
@@ -47,16 +50,16 @@ app.use("/api/sessions", sessionsLimiter, supabaseAuth, sessionRoutes);
|
|||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
res.status(404).json({ error: "Endpoint not found" });
|
res.status(404).json({ error: "Endpoint not found" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global error handler — never leak stack traces or internals to the client
|
// Global error handler — never leak stack traces or internals to the client
|
||||||
// (Express's default handler emits HTML stack dumps in non-production).
|
// (Express's default handler emits HTML stack dumps in non-production).
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
app.use((err, _req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
console.error("Unhandled error:", err?.message);
|
console.error("Unhandled error:", err?.message);
|
||||||
if (res.headersSent) return;
|
if (res.headersSent) return;
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
+48
-34
@@ -3,12 +3,11 @@
|
|||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useMatch, useNavigate } from "react-router-dom";
|
||||||
import Sidebar from "./components/sidebar/Sidebar";
|
import Sidebar from "./components/sidebar/Sidebar";
|
||||||
import TopNav from "./components/top-nav/TopNav";
|
import TopNav from "./components/top-nav/TopNav";
|
||||||
import ChatHistory from "./components/chat-history/ChatHistory";
|
import ChatHistory from "./components/chat-history/ChatHistory";
|
||||||
import ChatInput from "./components/chat-input/ChatInput";
|
import ChatInput from "./components/chat-input/ChatInput";
|
||||||
import SelectorFlow from "./components/selector-flow/SelectorFlow";
|
|
||||||
import { useAuth } from "./lib/auth/SupabaseAuth";
|
import { useAuth } from "./lib/auth/SupabaseAuth";
|
||||||
import SignInModal from "./lib/auth/SignIn";
|
import SignInModal from "./lib/auth/SignIn";
|
||||||
import { resetAppState } from "./appState";
|
import { resetAppState } from "./appState";
|
||||||
@@ -94,9 +93,10 @@ const chapterData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated, user, signIn, signUp, signOut, getToken } = useAuth();
|
const { isAuthenticated, user, signIn, signUp, signOut, getToken } =
|
||||||
|
useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { chatId: routeChatId } = useParams();
|
const routeChatId = useMatch("/chat/:chatId")?.params.chatId ?? null;
|
||||||
// Guards deep-link loading so it only fires once per route/authenticated change.
|
// Guards deep-link loading so it only fires once per route/authenticated change.
|
||||||
const chatLoadHandledRef = useRef(null);
|
const chatLoadHandledRef = useRef(null);
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
@@ -166,7 +166,7 @@ function App() {
|
|||||||
chatLoadHandledRef.current = null;
|
chatLoadHandledRef.current = null;
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, navigate, routeChatId]);
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
// Deep link: landing on /chat/:chatId loads that conversation (signed-in
|
// Deep link: landing on /chat/:chatId loads that conversation (signed-in
|
||||||
// users only — anonymous trials are in-memory, so a refreshed trial URL
|
// users only — anonymous trials are in-memory, so a refreshed trial URL
|
||||||
@@ -178,8 +178,6 @@ function App() {
|
|||||||
handleChatSelect(routeChatId);
|
handleChatSelect(routeChatId);
|
||||||
}, [routeChatId, isAuthenticated]);
|
}, [routeChatId, isAuthenticated]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ─── Auth handlers ───
|
// ─── Auth handlers ───
|
||||||
const handleAuthError = (msg) => {
|
const handleAuthError = (msg) => {
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
@@ -196,7 +194,10 @@ function App() {
|
|||||||
const headers = {};
|
const headers = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/api/sessions`, { headers, credentials: "include" });
|
const res = await fetch(`${API_BASE}/api/sessions`, {
|
||||||
|
headers,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSessions(data);
|
setSessions(data);
|
||||||
@@ -250,7 +251,10 @@ function App() {
|
|||||||
const headers = {};
|
const headers = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, { headers, credentials: "include" });
|
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, {
|
||||||
|
headers,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Session not found");
|
throw new Error("Session not found");
|
||||||
}
|
}
|
||||||
@@ -338,9 +342,9 @@ function App() {
|
|||||||
const body = {
|
const body = {
|
||||||
text: text.trim(),
|
text: text.trim(),
|
||||||
chatId: fetchChatId,
|
chatId: fetchChatId,
|
||||||
grade: activeGrade !== DEFAULTS.grade ? activeGrade : undefined,
|
grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade,
|
||||||
subject: activeSubject !== DEFAULTS.subject ? activeSubject : undefined,
|
subject: activeSubject === DEFAULTS.subject ? undefined : activeSubject,
|
||||||
chapter: activeChapter !== DEFAULTS.chapter ? activeChapter : undefined,
|
chapter: activeChapter === DEFAULTS.chapter ? undefined : activeChapter,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open SSE connection
|
// Open SSE connection
|
||||||
@@ -406,17 +410,19 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
if (data.type === "session") {
|
if (data.type === "session") {
|
||||||
// Remember which session this conversation belongs to so
|
|
||||||
// follow-up messages reuse it (history stays together).
|
|
||||||
// For anonymous users this is the in-memory trial id.
|
|
||||||
setActiveChat(data.id);
|
setActiveChat(data.id);
|
||||||
// Only reflect signed-in sessions in the URL. An anonymous
|
|
||||||
// trial id is a capability (no server ownership check), so
|
|
||||||
// baking it into the URL would leak it via history/logs/
|
|
||||||
// referer — keep it in in-memory state and the POST body only.
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
navigate(`/chat/${data.id}`, { replace: true });
|
// Persist the deep-link without dispatching a router
|
||||||
|
// navigation. React Router navigation rerenders the route
|
||||||
|
// tree during the active stream; history.replaceState only
|
||||||
|
// updates the address bar, so chat state remains untouched.
|
||||||
chatLoadHandledRef.current = data.id;
|
chatLoadHandledRef.current = data.id;
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
"",
|
||||||
|
`/chat/${encodeURIComponent(data.id)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (data.type === "limit") {
|
} else if (data.type === "limit") {
|
||||||
// Anonymous free-message budget remaining (server truth).
|
// Anonymous free-message budget remaining (server truth).
|
||||||
@@ -425,19 +431,15 @@ function App() {
|
|||||||
assistantText += data.content;
|
assistantText += data.content;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantId
|
m.id === assistantId ? { ...m, text: assistantText } : m,
|
||||||
? { ...m, text: assistantText }
|
),
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} else if (data.type === "done") {
|
} else if (data.type === "done") {
|
||||||
// Mark as non-streaming
|
// Mark as non-streaming
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantId
|
m.id === assistantId ? { ...m, streaming: false } : m,
|
||||||
? { ...m, streaming: false }
|
),
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} else if (data.type === "error") {
|
} else if (data.type === "error") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
@@ -448,8 +450,8 @@ function App() {
|
|||||||
text: `⚠️ Error: ${data.message}`,
|
text: `⚠️ Error: ${data.message}`,
|
||||||
streaming: false,
|
streaming: false,
|
||||||
}
|
}
|
||||||
: m
|
: m,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -461,7 +463,8 @@ function App() {
|
|||||||
setIsTyping(false);
|
setIsTyping(false);
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
|
|
||||||
// Refresh sessions to update timestamps (signed-in users only)
|
// Refresh the sidebar without changing routes. The active session id
|
||||||
|
// already lives in state and is reused by the next message.
|
||||||
if (isAuthenticated) loadSessions();
|
if (isAuthenticated) loadSessions();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -478,14 +481,23 @@ function App() {
|
|||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isTyping, activeChat, activeGrade, activeSubject, activeChapter, isAnonymous, anonRemaining, isAuthenticated]
|
[
|
||||||
|
isTyping,
|
||||||
|
activeChat,
|
||||||
|
activeGrade,
|
||||||
|
activeSubject,
|
||||||
|
activeChapter,
|
||||||
|
isAnonymous,
|
||||||
|
anonRemaining,
|
||||||
|
isAuthenticated,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePromptClick = useCallback(
|
const handlePromptClick = useCallback(
|
||||||
(promptText) => {
|
(promptText) => {
|
||||||
handleSend(promptText);
|
handleSend(promptText);
|
||||||
},
|
},
|
||||||
[handleSend]
|
[handleSend],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ─── Selector step handlers ─── */
|
/* ─── Selector step handlers ─── */
|
||||||
@@ -619,7 +631,9 @@ function App() {
|
|||||||
onPromptClick={handlePromptClick}
|
onPromptClick={handlePromptClick}
|
||||||
isTyping={isTyping}
|
isTyping={isTyping}
|
||||||
selectorStep={selectorStep}
|
selectorStep={selectorStep}
|
||||||
selectorData={selectorStep !== "chat" ? selectorData[selectorStep] : null}
|
selectorData={
|
||||||
|
selectorStep === "chat" ? null : selectorData[selectorStep]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,167 +2,256 @@
|
|||||||
ChatHistory Component — padhle (Refined Workspace)
|
ChatHistory Component — padhle (Refined Workspace)
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import Message from "../message/Message";
|
import Message from "../message/Message";
|
||||||
import SelectorFlow from "../selector-flow/SelectorFlow";
|
import SelectorFlow from "../selector-flow/SelectorFlow";
|
||||||
import "./ChatHistory.css";
|
import "./ChatHistory.css";
|
||||||
|
|
||||||
const ChatHistory = ({
|
const ChatHistory = ({
|
||||||
messages,
|
messages,
|
||||||
activeSubject,
|
activeSubject,
|
||||||
activeGrade,
|
activeGrade,
|
||||||
activeChapter,
|
activeChapter,
|
||||||
onPromptClick,
|
onPromptClick,
|
||||||
isTyping,
|
isTyping,
|
||||||
selectorStep,
|
selectorStep,
|
||||||
selectorData,
|
selectorData,
|
||||||
}) => {
|
}) => {
|
||||||
const hasMessages = messages.length > 0;
|
const hasMessages = messages.length > 0;
|
||||||
const isSelector = selectorStep !== "chat";
|
const isSelector = selectorStep !== "chat";
|
||||||
|
|
||||||
const suggestions = [
|
const suggestions = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
icon: "eco",
|
icon: "eco",
|
||||||
iconFilled: true,
|
iconFilled: true,
|
||||||
color: "--color-accent-green",
|
color: "--color-accent-green",
|
||||||
colorBg: "--color-accent-green-bg",
|
colorBg: "--color-accent-green-bg",
|
||||||
keywords: ["photosynthesis"],
|
keywords: ["photosynthesis"],
|
||||||
text: "Explain how ",
|
text: "Explain how ",
|
||||||
textEnd: " works in simple terms",
|
textEnd: " works in simple terms",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
icon: "calculate",
|
icon: "calculate",
|
||||||
iconFilled: false,
|
iconFilled: false,
|
||||||
color: "--color-accent-blue",
|
color: "--color-accent-blue",
|
||||||
colorBg: "--color-accent-blue-bg",
|
colorBg: "--color-accent-blue-bg",
|
||||||
keywords: ["derivatives"],
|
keywords: ["derivatives"],
|
||||||
text: "Help me understand ",
|
text: "Help me understand ",
|
||||||
textEnd: " in calculus",
|
textEnd: " in calculus",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
icon: "account_balance",
|
icon: "account_balance",
|
||||||
iconFilled: false,
|
iconFilled: false,
|
||||||
color: "--color-accent-orange",
|
color: "--color-accent-orange",
|
||||||
colorBg: "--color-accent-orange-bg",
|
colorBg: "--color-accent-orange-bg",
|
||||||
keywords: ["Industrial"],
|
keywords: ["Industrial"],
|
||||||
text: "What caused the ",
|
text: "What caused the ",
|
||||||
textEnd: " Revolution?",
|
textEnd: " Revolution?",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
icon: "edit",
|
icon: "edit",
|
||||||
iconFilled: true,
|
iconFilled: true,
|
||||||
color: "--color-accent-purple",
|
color: "--color-accent-purple",
|
||||||
colorBg: "--color-accent-purple-bg",
|
colorBg: "--color-accent-purple-bg",
|
||||||
keywords: ["Romeo and Juliet"],
|
keywords: ["Romeo and Juliet"],
|
||||||
text: "Help me analyze themes in ",
|
text: "Help me analyze themes in ",
|
||||||
textEnd: "",
|
textEnd: "",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const fullPromptTexts = suggestions.map((s) => s.text + s.keywords.join(" ") + s.textEnd);
|
const fullPromptTexts = suggestions.map(
|
||||||
|
(s) => s.text + s.keywords.join(" ") + s.textEnd,
|
||||||
|
);
|
||||||
|
|
||||||
const streamingMsg = messages.find((m) => m.streaming);
|
const streamingMsg = messages.find((m) => m.streaming);
|
||||||
|
const historyRef = useRef(null);
|
||||||
|
|
||||||
/* ─── Messages mode ─── */
|
// Scroll only the chat pane, and only when a message is added. Streaming
|
||||||
if (hasMessages) {
|
// token updates must not move the page or restart a smooth-scroll animation.
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="chat-history">
|
const history = historyRef.current;
|
||||||
<div className="chat-history__list">
|
if (history) history.scrollTop = history.scrollHeight;
|
||||||
{messages.map((message) => (
|
}, [messages.length]);
|
||||||
<Message key={message.id} message={message} />
|
|
||||||
))}
|
|
||||||
{isTyping && !streamingMsg && (
|
|
||||||
<Message message={{ id: "typing", role: "assistant", text: "", streaming: true }} />
|
|
||||||
)}
|
|
||||||
<div ref={(el) => el && el.scrollIntoView({ behavior: "smooth", block: "end" })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Selector mode (grade → subject → chapter) ─── */
|
/* ─── Messages mode ─── */
|
||||||
if (isSelector && selectorData) {
|
if (hasMessages) {
|
||||||
return (
|
return (
|
||||||
<div className="chat-history">
|
<div className="chat-history" ref={historyRef}>
|
||||||
<SelectorFlow
|
<div className="chat-history__list">
|
||||||
title={selectorData.title}
|
{messages.map((message) => (
|
||||||
subtitle={selectorData.subtitle}
|
<Message
|
||||||
selected={selectorData.selected}
|
key={message.id}
|
||||||
items={selectorData.items}
|
message={message}
|
||||||
onSelect={selectorData.onSelect}
|
/>
|
||||||
step={selectorStep}
|
))}
|
||||||
/>
|
{isTyping && !streamingMsg && (
|
||||||
</div>
|
<Message
|
||||||
);
|
message={{
|
||||||
}
|
id: "typing",
|
||||||
|
role: "assistant",
|
||||||
|
text: "",
|
||||||
|
streaming: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Welcome / suggestions mode ─── */
|
/* ─── Selector mode (grade → subject → chapter) ─── */
|
||||||
return (
|
if (isSelector && selectorData) {
|
||||||
<div className="chat-history">
|
return (
|
||||||
<div className="chat-history__focus">
|
<div className="chat-history">
|
||||||
{/* Welcome Header */}
|
<SelectorFlow
|
||||||
<div className="chat-history__welcome">
|
title={selectorData.title}
|
||||||
<div className="chat-history__welcome-icon">
|
subtitle={selectorData.subtitle}
|
||||||
<span className="material-symbols-outlined" style={{ fontWeight: 200 }}>menu_book</span>
|
selected={selectorData.selected}
|
||||||
<span className="material-symbols-outlined chat-history__welcome-sparkle">auto_awesome</span>
|
items={selectorData.items}
|
||||||
</div>
|
onSelect={selectorData.onSelect}
|
||||||
<h2 className="chat-history__welcome-title">Welcome to padhle</h2>
|
step={selectorStep}
|
||||||
<p className="chat-history__welcome-subtitle">Your AI tutor for grades 6–12. Ask anything!</p>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Bento Grid Suggestions */}
|
/* ─── Welcome / suggestions mode ─── */
|
||||||
<div className="chat-history__bento">
|
return (
|
||||||
{suggestions.map((suggestion) => {
|
<div className="chat-history">
|
||||||
const colorVar = suggestion.color.replace("--", "var(");
|
<div className="chat-history__focus">
|
||||||
const bgColorVar = suggestion.colorBg.replace("--", "var(");
|
{/* Welcome Header */}
|
||||||
|
<div className="chat-history__welcome">
|
||||||
|
<div className="chat-history__welcome-icon">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{
|
||||||
|
fontWeight: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
menu_book
|
||||||
|
</span>
|
||||||
|
<span className="material-symbols-outlined chat-history__welcome-sparkle">
|
||||||
|
auto_awesome
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="chat-history__welcome-title">
|
||||||
|
Welcome to padhle
|
||||||
|
</h2>
|
||||||
|
<p className="chat-history__welcome-subtitle">
|
||||||
|
Your AI tutor for grades 6–12.
|
||||||
|
Ask anything!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{/* Bento Grid Suggestions */}
|
||||||
<button
|
<div className="chat-history__bento">
|
||||||
key={suggestion.id}
|
{suggestions.map((suggestion) => {
|
||||||
className="chat-history__bento-card"
|
const colorVar =
|
||||||
onClick={() => {
|
suggestion.color.replace(
|
||||||
const text = fullPromptTexts[suggestion.id - 1];
|
"--",
|
||||||
onPromptClick(text);
|
"var(",
|
||||||
}}
|
);
|
||||||
>
|
const bgColorVar =
|
||||||
<div className="chat-history__bento-icon-wrap" style={{ backgroundColor: `var(${bgColorVar})` }}>
|
suggestion.colorBg.replace(
|
||||||
<span className="material-symbols-outlined" style={{ color: `var(${colorVar})`, fontWeight: suggestion.iconFilled ? 700 : 200 }}>
|
"--",
|
||||||
{suggestion.icon}
|
"var(",
|
||||||
</span>
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={
|
||||||
|
suggestion.id
|
||||||
|
}
|
||||||
|
className="chat-history__bento-card"
|
||||||
|
onClick={() => {
|
||||||
|
const text =
|
||||||
|
fullPromptTexts[
|
||||||
|
suggestion.id -
|
||||||
|
1
|
||||||
|
];
|
||||||
|
onPromptClick(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="chat-history__bento-icon-wrap"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `var(${bgColorVar})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{
|
||||||
|
color: `var(${colorVar})`,
|
||||||
|
fontWeight: suggestion.iconFilled
|
||||||
|
? 700
|
||||||
|
: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
suggestion.icon
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat-history__bento-text">
|
||||||
|
<span className="chat-history__bento-text-main">
|
||||||
|
{
|
||||||
|
suggestion.text
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="chat-history__bento-text-highlight"
|
||||||
|
style={{
|
||||||
|
color: `var(${colorVar})`,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
suggestion
|
||||||
|
.keywords[0]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{
|
||||||
|
suggestion.textEnd
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined chat-history__bento-arrow">
|
||||||
|
arrow_forward
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Panel */}
|
||||||
|
<div className="chat-history__info">
|
||||||
|
<div className="chat-history__info-icon-wrap">
|
||||||
|
<span className="material-symbols-outlined">
|
||||||
|
lightbulb
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat-history__info-content">
|
||||||
|
<h4 className="chat-history__info-title">
|
||||||
|
Focused on your learning
|
||||||
|
</h4>
|
||||||
|
<p className="chat-history__info-text">
|
||||||
|
I'll answer questions
|
||||||
|
related to your selected
|
||||||
|
subject and chapter
|
||||||
|
<br />
|
||||||
|
using your textbook
|
||||||
|
content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-history__bento-text">
|
);
|
||||||
<span className="chat-history__bento-text-main">{suggestion.text}</span>
|
|
||||||
<span className="chat-history__bento-text-highlight" style={{ color: `var(${colorVar})`, fontWeight: 600 }}>
|
|
||||||
{suggestion.keywords[0]}
|
|
||||||
</span>
|
|
||||||
{suggestion.textEnd}
|
|
||||||
</div>
|
|
||||||
<span className="material-symbols-outlined chat-history__bento-arrow">arrow_forward</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Panel */}
|
|
||||||
<div className="chat-history__info">
|
|
||||||
<div className="chat-history__info-icon-wrap">
|
|
||||||
<span className="material-symbols-outlined">lightbulb</span>
|
|
||||||
</div>
|
|
||||||
<div className="chat-history__info-content">
|
|
||||||
<h4 className="chat-history__info-title">Focused on your learning</h4>
|
|
||||||
<p className="chat-history__info-text">
|
|
||||||
I'll answer questions related to your selected subject and chapter
|
|
||||||
<br />
|
|
||||||
using your textbook content.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ChatHistory;
|
export default ChatHistory;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
Message Component — padhle (Refined Workspace)
|
Message Component — padhle (Refined Workspace)
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
import rehypeKatex from "rehype-katex";
|
import rehypeKatex from "rehype-katex";
|
||||||
@@ -12,61 +11,76 @@ import "./Message.css";
|
|||||||
const Message = ({ message }) => {
|
const Message = ({ message }) => {
|
||||||
const { role, text, streaming } = message;
|
const { role, text, streaming } = message;
|
||||||
const isUser = role === "user";
|
const isUser = role === "user";
|
||||||
const endRef = useRef(null);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when text updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (endRef.current) {
|
|
||||||
endRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
||||||
}
|
|
||||||
}, [text, streaming]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`message ${isUser ? "message--user" : "message--assistant"}`}>
|
<div
|
||||||
|
className={`message ${isUser ? "message--user" : "message--assistant"}`}
|
||||||
|
>
|
||||||
{!isUser && (
|
{!isUser && (
|
||||||
<div className="message__avatar">
|
<div className="message__avatar">
|
||||||
<div className="message__avatar-icon">
|
<div className="message__avatar-icon">
|
||||||
<span className="material-symbols-outlined" style={{ fontWeight: 700 }}>school</span>
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
school
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="message__bubble">
|
<div className="message__bubble">
|
||||||
<div className={`message__text ${!isUser ? "markdown-body" : ""}`}>
|
<div className={`message__text ${isUser ? "" : "markdown-body"}`}>
|
||||||
{isUser ? (
|
{isUser
|
||||||
text
|
? text
|
||||||
) : (
|
: text && (
|
||||||
text && (
|
<ReactMarkdown
|
||||||
<ReactMarkdown
|
remarkPlugins={[remarkMath]}
|
||||||
remarkPlugins={[remarkMath]}
|
rehypePlugins={[rehypeKatex]}
|
||||||
rehypePlugins={[rehypeKatex]}
|
>
|
||||||
>
|
{text}
|
||||||
{text}
|
</ReactMarkdown>
|
||||||
</ReactMarkdown>
|
)}
|
||||||
)
|
{streaming && (
|
||||||
|
<span className="message__typing-dots">
|
||||||
|
<span className="message__typing-dot"></span>
|
||||||
|
<span className="message__typing-dot"></span>
|
||||||
|
<span className="message__typing-dot"></span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{streaming && <span className="message__typing-dots">
|
|
||||||
<span className="message__typing-dot"></span>
|
|
||||||
<span className="message__typing-dot"></span>
|
|
||||||
<span className="message__typing-dot"></span>
|
|
||||||
</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons for assistant messages */}
|
{/* Action buttons for assistant messages */}
|
||||||
{!isUser && text && (
|
{!isUser && text && (
|
||||||
<div className="message__actions">
|
<div className="message__actions">
|
||||||
<button className="message__action" aria-label="Helpful" title="Helpful">
|
<button
|
||||||
|
className="message__action"
|
||||||
|
aria-label="Helpful"
|
||||||
|
title="Helpful"
|
||||||
|
>
|
||||||
<span className="material-symbols-outlined">thumb_up</span>
|
<span className="material-symbols-outlined">thumb_up</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="message__action" aria-label="Save note" title="Save Note">
|
<button
|
||||||
|
className="message__action"
|
||||||
|
aria-label="Save note"
|
||||||
|
title="Save Note"
|
||||||
|
>
|
||||||
<span className="material-symbols-outlined">bookmark_border</span>
|
<span className="material-symbols-outlined">bookmark_border</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="message__action message__action--primary" aria-label="Explain deeper" title="Explain Deeper">
|
<button
|
||||||
<span className="material-symbols-outlined" style={{ fontWeight: 700 }}>menu_book</span>
|
className="message__action message__action--primary"
|
||||||
|
aria-label="Explain deeper"
|
||||||
|
title="Explain Deeper"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
menu_book
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={endRef} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,14 +14,10 @@ ReactDOM.createRoot(document.getElementById("root")).render(
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Both paths render the same App shell; the chat id is read via
|
{/* One route owns the app shell so URL changes never remount it. */}
|
||||||
useParams() to support deep links like /chat/:chatId. A catch-all
|
|
||||||
keeps unknown paths on the app (Vite SPA fallback serves index.html). */}
|
|
||||||
<Route path="/" element={<App />} />
|
|
||||||
<Route path="/chat/:chatId" element={<App />} />
|
|
||||||
<Route path="*" element={<App />} />
|
<Route path="*" element={<App />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
│ 🔧 Development Tools │
|
||||||
|
├─────────┬────────────────────────────┤
|
||||||
|
│ Studio │ http://http://192.168.50.153/:54323 │
|
||||||
|
│ Mailpit │ http://http://192.168.50.153/:54324 │
|
||||||
|
│ MCP │ http://http://192.168.50.153/:54321/mcp │
|
||||||
|
╰─────────┴────────────────────────────╯
|
||||||
|
|
||||||
|
╭──────────────────────────────────────────────────────╮
|
||||||
|
│ 🌐 APIs │
|
||||||
|
├────────────────┬─────────────────────────────────────┤
|
||||||
|
│ Project URL │ http://http://192.168.50.153/:54321 │
|
||||||
|
│ REST │ http://http://192.168.50.153/:54321/rest/v1 │
|
||||||
|
│ GraphQL │ http://http://192.168.50.153/:54321/graphql/v1 │
|
||||||
|
│ Edge Functions │ http://http://192.168.50.153/:54321/functions/v1 │
|
||||||
|
╰────────────────┴─────────────────────────────────────╯
|
||||||
|
|
||||||
|
╭───────────────────────────────────────────────────────────────╮
|
||||||
|
│ ⛁ Database │
|
||||||
|
├─────┬─────────────────────────────────────────────────────────┤
|
||||||
|
│ URL │ postgresql://postgres:postgres@http://192.168.50.153/:54322/postgres │
|
||||||
|
╰─────┴─────────────────────────────────────────────────────────╯
|
||||||
|
|
||||||
|
╭──────────────────────────────────────────────────────────────╮
|
||||||
|
│ 🔑 Authentication Keys │
|
||||||
|
├─────────────┬────────────────────────────────────────────────┤
|
||||||
|
│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │
|
||||||
|
│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │
|
||||||
|
╰─────────────┴────────────────────────────────────────────────╯
|
||||||
|
|
||||||
|
╭───────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ 📦 Storage (S3) │
|
||||||
|
├────────────┬──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ URL │ http://http://192.168.50.153/:54321/storage/v1/s3 │
|
||||||
|
│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │
|
||||||
|
│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │
|
||||||
|
│ Region │ local │
|
||||||
|
╰────────────┴──────────
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"frontend": {
|
||||||
|
"root": ".",
|
||||||
|
"framework": "vite"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"root": "backend",
|
||||||
|
"entrypoint": "src/app.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/api(/.*)?",
|
||||||
|
"destination": {
|
||||||
|
"type": "service",
|
||||||
|
"service": "backend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"destination": {
|
||||||
|
"type": "service",
|
||||||
|
"service": "frontend"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user