Add Langfuse as a self-hosted eval and trace viewer
This commit is contained in:
+75
-12
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChatMessage, Citation, MonographPickerState } from "@duoc-thu/shared-types";
|
||||
import { ChatPanel } from "./_components/ChatPanel";
|
||||
import { Sidebar, ChatSession } from "./_components/Sidebar";
|
||||
@@ -12,14 +12,13 @@ function createSessionId() {
|
||||
return `session-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
// Feature-List #25: the session id is the key `/v1/rag/history` scopes its
|
||||
// listing by, so persisting it is a prerequisite for history surviving a
|
||||
// refresh at all — not just a UX nicety. Chat MESSAGE CONTENT is
|
||||
// deliberately NOT persisted here (server never stores generated answer
|
||||
// text/blocks either, only decision/reason metadata — see
|
||||
// `PostgresTraceRepository.list_by_conversation`'s docstring): a resumed
|
||||
// session's transcript starts empty, matching the spec's own "re-run the
|
||||
// query" wording rather than "replay the old answer".
|
||||
// The session id is the key both `/api/history` (quick-rerun list) and
|
||||
// `/api/transcript` (full replay) scope their reads by, so persisting it is
|
||||
// a prerequisite for either surviving a refresh at all. The session LIST
|
||||
// itself still lives only in this browser's localStorage — there is no
|
||||
// auth yet to scope a server-side list by, so a different browser/device
|
||||
// (or cleared storage) loses the sidebar entries even though the
|
||||
// transcripts themselves are still in Postgres, keyed by the same id.
|
||||
const SESSIONS_STORAGE_KEY = "dt_sessions";
|
||||
const CURRENT_SESSION_STORAGE_KEY = "dt_current_session_id";
|
||||
|
||||
@@ -34,6 +33,20 @@ function loadStoredSessions(): ChatSession[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_TITLE = "Phiên tra cứu mới";
|
||||
|
||||
// Real usage testing 2026-08-20: a session's sidebar entry stayed
|
||||
// "Phiên tra cứu mới" forever (every session, indistinguishable) and
|
||||
// picking one loaded an empty chat with no way to continue it — the spec's
|
||||
// "history is a re-run shortcut, not a replay" call turned out to read as
|
||||
// broken, not minimal. `/api/transcript` now persists and replays the full
|
||||
// turn (both question and answer), so a session title can be derived from
|
||||
// its own first question instead of staying a placeholder forever.
|
||||
function deriveTitle(text: string): string {
|
||||
const trimmed = text.trim().replace(/\s+/g, " ");
|
||||
return trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
||||
@@ -41,6 +54,38 @@ export default function ChatPage() {
|
||||
const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null);
|
||||
const [monographPicker, setMonographPicker] = useState<MonographPickerState | null>(null);
|
||||
|
||||
// Guards `hydrateSession` to one fetch per session per page load: without
|
||||
// it, switching back and forth between two sessions would re-fetch (and
|
||||
// briefly flash) every time, and a session with messages just sent this
|
||||
// visit could get clobbered by a fetch racing behind it.
|
||||
const hydratedSessionIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const hydrateSession = (id: string) => {
|
||||
if (hydratedSessionIdsRef.current.has(id)) return;
|
||||
hydratedSessionIdsRef.current.add(id);
|
||||
fetch(`/api/transcript?conversation_id=${encodeURIComponent(id)}`)
|
||||
.then((res) => (res.ok ? res.json() : { messages: [] }))
|
||||
.then((data: { messages?: ChatMessage[] }) => {
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
if (messages.length === 0) return;
|
||||
setMessagesBySession((prev) => ({ ...prev, [id]: messages }));
|
||||
const firstUser = messages.find((m) => m.role === "user");
|
||||
if (firstUser) {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === id && s.title === DEFAULT_SESSION_TITLE
|
||||
? { ...s, title: deriveTitle(firstUser.content) }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort — the session just starts blank, same as before this
|
||||
// feature existed, rather than blocking on a slow/down ai-service.
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadStoredSessions();
|
||||
if (stored) {
|
||||
@@ -53,13 +98,17 @@ export default function ChatPage() {
|
||||
})();
|
||||
setSessions(stored);
|
||||
setMessagesBySession(Object.fromEntries(stored.map((s) => [s.id, []])));
|
||||
setCurrentSessionId(stored.find((s) => s.id === storedCurrent)?.id ?? stored[0].id);
|
||||
const resolvedId = stored.find((s) => s.id === storedCurrent)?.id ?? stored[0].id;
|
||||
setCurrentSessionId(resolvedId);
|
||||
hydrateSession(resolvedId);
|
||||
return;
|
||||
}
|
||||
const id = createSessionId();
|
||||
setSessions([{ id, title: "Phiên tra cứu mới", updatedAt: new Date().toISOString() }]);
|
||||
setSessions([{ id, title: DEFAULT_SESSION_TITLE, updatedAt: new Date().toISOString() }]);
|
||||
setMessagesBySession({ [id]: [] });
|
||||
setCurrentSessionId(id);
|
||||
hydratedSessionIdsRef.current.add(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Persist whenever the session list / active session changes — covers
|
||||
@@ -98,12 +147,15 @@ export default function ChatPage() {
|
||||
const newId = createSessionId();
|
||||
const newSession: ChatSession = {
|
||||
id: newId,
|
||||
title: "Phiên tra cứu mới",
|
||||
title: DEFAULT_SESSION_TITLE,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setSessions((prev) => [newSession, ...prev]);
|
||||
setMessagesBySession((prev) => ({ ...prev, [newId]: [] }));
|
||||
setCurrentSessionId(newId);
|
||||
// Nothing on the server yet for a brand-new session — skip the wasted
|
||||
// round trip `hydrateSession` would otherwise make on first select.
|
||||
hydratedSessionIdsRef.current.add(newId);
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
@@ -113,6 +165,7 @@ export default function ChatPage() {
|
||||
|
||||
const handleSelectSession = (id: string) => {
|
||||
setCurrentSessionId(id);
|
||||
hydrateSession(id);
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
@@ -131,6 +184,7 @@ export default function ChatPage() {
|
||||
const remaining = sessions.filter((s) => s.id !== id);
|
||||
if (remaining.length > 0) {
|
||||
setCurrentSessionId(remaining[0].id);
|
||||
hydrateSession(remaining[0].id);
|
||||
} else {
|
||||
handleNewChat();
|
||||
}
|
||||
@@ -148,6 +202,15 @@ export default function ChatPage() {
|
||||
setMessagesBySession((prev) => {
|
||||
const existing = prev[sessionId] ?? [];
|
||||
const next = typeof update === "function" ? update(existing) : update;
|
||||
// Title the session off its own first question immediately, rather
|
||||
// than leaving every session as the identical, indistinguishable
|
||||
// placeholder until a later reload re-fetches the transcript.
|
||||
if (existing.length === 0 && next.length > 0 && next[0].role === "user") {
|
||||
const title = deriveTitle(next[0].content);
|
||||
setSessions((prevSessions) =>
|
||||
prevSessions.map((s) => (s.id === sessionId ? { ...s, title } : s))
|
||||
);
|
||||
}
|
||||
return { ...prev, [sessionId]: next };
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user