Wire up query history: localStorage session persistence + sidebar UI

This commit is contained in:
2026-08-14 17:44:36 +07:00
parent 9be5819710
commit 057d4ed9dc
23 changed files with 1231 additions and 30 deletions
+59
View File
@@ -12,6 +12,28 @@ 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".
const SESSIONS_STORAGE_KEY = "dt_sessions";
const CURRENT_SESSION_STORAGE_KEY = "dt_current_session_id";
function loadStoredSessions(): ChatSession[] | null {
try {
const raw = localStorage.getItem(SESSIONS_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) && parsed.length > 0 ? parsed : null;
} catch {
return null;
}
}
export default function ChatPage() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string>("");
@@ -19,12 +41,49 @@ export default function ChatPage() {
const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null);
useEffect(() => {
const stored = loadStoredSessions();
if (stored) {
const storedCurrent = (() => {
try {
return localStorage.getItem(CURRENT_SESSION_STORAGE_KEY);
} catch {
return null;
}
})();
setSessions(stored);
setMessagesBySession(Object.fromEntries(stored.map((s) => [s.id, []])));
setCurrentSessionId(stored.find((s) => s.id === storedCurrent)?.id ?? stored[0].id);
return;
}
const id = createSessionId();
setSessions([{ id, title: "Phiên tra cứu mới", updatedAt: new Date().toISOString() }]);
setMessagesBySession({ [id]: [] });
setCurrentSessionId(id);
}, []);
// Persist whenever the session list / active session changes — covers
// new/deleted sessions and switching between them. Guarded on non-empty
// so the pre-mount-effect empty state never overwrites a real stored
// list with `[]`.
useEffect(() => {
if (sessions.length === 0) return;
try {
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(sessions));
} catch {
// Storage full or unavailable (e.g. private browsing) — the session
// list simply won't survive a refresh this time.
}
}, [sessions]);
useEffect(() => {
if (!currentSessionId) return;
try {
localStorage.setItem(CURRENT_SESSION_STORAGE_KEY, currentSessionId);
} catch {
// ignore — same fallback as above
}
}, [currentSessionId]);
// Citation & Evidence Panel State
const [citations, setCitations] = useState<Citation[]>([]);
const [activeCitationIndex, setActiveCitationIndex] = useState<number | null>(null);