Wire up query history: localStorage session persistence + sidebar UI
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
History,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@duoc-thu/ui";
|
||||
@@ -59,6 +60,12 @@ const QUICK_PROMPTS = [
|
||||
},
|
||||
];
|
||||
|
||||
interface HistoryItem {
|
||||
trace_id: string;
|
||||
query: string;
|
||||
decision: string;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
currentSessionId,
|
||||
sessions,
|
||||
@@ -70,6 +77,32 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
|
||||
|
||||
// Feature-List #25: past queries FOR THIS SESSION, most recent first —
|
||||
// click one to re-run it (via `onQuickQuery`, same path the hardcoded
|
||||
// quick-prompts below already use). Deliberately re-fetched whenever the
|
||||
// active session changes, not lifted to page.tsx: `Composer.tsx`'s own
|
||||
// autocomplete fetch already establishes the pattern of a component
|
||||
// owning its own small read, rather than everything prop-drilled down.
|
||||
useEffect(() => {
|
||||
if (!currentSessionId) {
|
||||
setHistoryItems([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetch(`/api/history?conversation_id=${encodeURIComponent(currentSessionId)}`)
|
||||
.then((res) => (res.ok ? res.json() : { items: [] }))
|
||||
.then((data) => {
|
||||
if (!cancelled) setHistoryItems(Array.isArray(data?.items) ? data.items : []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHistoryItems([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentSessionId]);
|
||||
|
||||
const filteredSessions = sessions.filter((s) =>
|
||||
s.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
@@ -204,6 +237,33 @@ export function Sidebar({
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Query History — real past questions for this session (Feature-
|
||||
List #25), click to re-run the same question. Empty when the
|
||||
session has no persisted queries yet (a brand-new session, or
|
||||
history backend unavailable) — the quick-prompt templates below
|
||||
still work as a starting point either way. */}
|
||||
{historyItems.length > 0 && (
|
||||
<div className="pt-4 px-2 border-t border-border-subtle mt-4">
|
||||
<div className="pb-2 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase flex items-center gap-1">
|
||||
<History className="w-3 h-3 text-accent-primary" />
|
||||
<span>Lịch sử câu hỏi</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{historyItems.map((item) => (
|
||||
<button
|
||||
key={item.trace_id}
|
||||
onClick={() => onQuickQuery(item.query)}
|
||||
title={item.query}
|
||||
className="w-full text-left p-2 rounded-xl bg-surface-elevated hover:bg-surface-hover border border-border-subtle text-txt-secondary hover:text-txt-primary text-[0.72rem] leading-snug transition-all flex items-center justify-between group"
|
||||
>
|
||||
<span className="truncate pr-1">{item.query}</span>
|
||||
<History className="w-3 h-3 text-accent-primary shrink-0 opacity-70 group-hover:opacity-100" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Prompts Section */}
|
||||
<div className="pt-4 px-2 border-t border-border-subtle mt-4">
|
||||
<div className="pb-2 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase flex items-center gap-1">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const API_GATEWAY_URL =
|
||||
process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8000";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const conversationId = searchParams.get("conversation_id")?.trim() || "";
|
||||
|
||||
if (!conversationId) {
|
||||
return NextResponse.json({ items: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const targetUrl = API_GATEWAY_URL.includes("/v1/rag")
|
||||
? `${API_GATEWAY_URL.replace(/\/query$/, "/history")}?conversation_id=${encodeURIComponent(conversationId)}`
|
||||
: `${API_GATEWAY_URL}/v1/rag/history?conversation_id=${encodeURIComponent(conversationId)}`;
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Client-Version": "1.0.0",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ items: [] });
|
||||
}
|
||||
|
||||
const data = await upstream.json();
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ items: [] });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user