visualize_chunk re-expanded chunks from scratch to recover their refs, which could not faithfully reproduce the original merge, scores, and clip — so a visualization could highlight different pages than the citation covered. Carry the cited items on Citation.doc_item_refs and resolve bounding boxes from them directly; re-expansion remains only as the fallback for callers with no stored context (CLI, inspector). Chat, inspector, the app endpoint, and the frontend pass the refs through.
165 lines
4.3 KiB
TypeScript
165 lines
4.3 KiB
TypeScript
export interface Citation {
|
|
index: number;
|
|
document_id: string;
|
|
chunk_id: string;
|
|
chunk_ids?: string[];
|
|
document_uri: string;
|
|
document_title: string | null;
|
|
page_numbers: number[];
|
|
headings: string[] | null;
|
|
content: string;
|
|
doc_item_refs?: string[];
|
|
}
|
|
|
|
// Matches RAGState from the backend skill
|
|
export interface RAGState {
|
|
citation_index: Record<string, Citation>;
|
|
citations: string[];
|
|
document_filter: string | null;
|
|
searches: Record<string, unknown[]>;
|
|
}
|
|
|
|
export interface StoredMessage {
|
|
id: string;
|
|
role?: string;
|
|
content?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface StoredSession {
|
|
id: string;
|
|
title: string;
|
|
messages: StoredMessage[];
|
|
ragState: RAGState;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
const SESSIONS_KEY = "haiku.rag.sessions";
|
|
const ACTIVE_SESSION_KEY = "haiku.rag.activeSession";
|
|
|
|
export function normalizeRAGState(state?: Partial<RAGState>): RAGState {
|
|
return {
|
|
citation_index: state?.citation_index ?? {},
|
|
citations: state?.citations ?? [],
|
|
document_filter: state?.document_filter ?? null,
|
|
searches: state?.searches ?? {},
|
|
};
|
|
}
|
|
|
|
export function getLatestCitations(state: RAGState): Citation[] {
|
|
return state.citations
|
|
.map((id) => state.citation_index[id])
|
|
.filter((c): c is Citation => c !== undefined);
|
|
}
|
|
|
|
export function getAllSessions(): StoredSession[] {
|
|
const raw = localStorage.getItem(SESSIONS_KEY);
|
|
if (!raw) return [];
|
|
try {
|
|
return JSON.parse(raw) as StoredSession[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function getSession(id: string): StoredSession | null {
|
|
return getAllSessions().find((s) => s.id === id) ?? null;
|
|
}
|
|
|
|
export function getActiveSessionId(): string | null {
|
|
return localStorage.getItem(ACTIVE_SESSION_KEY);
|
|
}
|
|
|
|
export function setActiveSessionId(id: string): void {
|
|
localStorage.setItem(ACTIVE_SESSION_KEY, id);
|
|
}
|
|
|
|
export function createSession(): StoredSession {
|
|
const now = new Date().toISOString();
|
|
const session: StoredSession = {
|
|
id: crypto.randomUUID(),
|
|
title: "New Session",
|
|
messages: [],
|
|
ragState: normalizeRAGState(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
const sessions = getAllSessions();
|
|
sessions.unshift(session);
|
|
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
|
|
setActiveSessionId(session.id);
|
|
return session;
|
|
}
|
|
|
|
export function saveSession(session: StoredSession): void {
|
|
const sessions = getAllSessions();
|
|
const idx = sessions.findIndex((s) => s.id === session.id);
|
|
if (idx >= 0) {
|
|
sessions[idx] = session;
|
|
} else {
|
|
sessions.unshift(session);
|
|
}
|
|
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
|
|
}
|
|
|
|
export function updateSessionMessages(
|
|
id: string,
|
|
messages: StoredMessage[],
|
|
ragState: RAGState,
|
|
): void {
|
|
const sessions = getAllSessions();
|
|
const idx = sessions.findIndex((s) => s.id === id);
|
|
if (idx < 0) return;
|
|
|
|
const session = sessions[idx];
|
|
session.messages = messages;
|
|
session.ragState = ragState;
|
|
session.updatedAt = new Date().toISOString();
|
|
|
|
// Derive title from first user message
|
|
if (session.title === "New Session") {
|
|
const firstUserMsg = messages.find(
|
|
(m) =>
|
|
m.content &&
|
|
typeof m.role === "string" &&
|
|
m.role.toLowerCase() === "user",
|
|
);
|
|
if (firstUserMsg?.content) {
|
|
session.title =
|
|
firstUserMsg.content.length > 60
|
|
? `${firstUserMsg.content.slice(0, 57)}...`
|
|
: firstUserMsg.content;
|
|
}
|
|
}
|
|
|
|
sessions[idx] = session;
|
|
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
|
|
}
|
|
|
|
export function deleteSession(id: string): void {
|
|
const sessions = getAllSessions().filter((s) => s.id !== id);
|
|
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
|
|
if (getActiveSessionId() === id) {
|
|
localStorage.removeItem(ACTIVE_SESSION_KEY);
|
|
}
|
|
}
|
|
|
|
export function exportSessionToMarkdown(session: StoredSession): void {
|
|
const lines: string[] = [`# ${session.title}`, ""];
|
|
for (const msg of session.messages) {
|
|
const role = typeof msg.role === "string" ? msg.role.toLowerCase() : "";
|
|
if (role === "user" && msg.content) {
|
|
lines.push(`**User:** ${msg.content}`, "");
|
|
} else if (role === "assistant" && msg.content) {
|
|
lines.push(`**Assistant:** ${msg.content}`, "");
|
|
}
|
|
}
|
|
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${session.title.replace(/[^a-zA-Z0-9]/g, "_")}.md`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|