Add Langfuse as a self-hosted eval and trace viewer

This commit is contained in:
2026-08-21 10:35:25 +07:00
parent 4490a1abf0
commit 53582b6030
12 changed files with 1129 additions and 280 deletions
+75 -12
View File
@@ -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 };
});
};