251 lines
8.5 KiB
JavaScript
251 lines
8.5 KiB
JavaScript
/**
|
|
* Owliver's conversation history.
|
|
*
|
|
* The panel already persisted the *current* thread per page, in sessionStorage,
|
|
* so returning to Candidates found the conversation you left there. What it had
|
|
* no notion of was a conversation you had *finished*: starting a new one
|
|
* overwrote the old one, and there was no way back to anything.
|
|
*
|
|
* This is that archive, and it is deliberately the same artefact — a list of
|
|
* threads in the shape the panel already stores. Two differences, both
|
|
* intentional:
|
|
*
|
|
* - **localStorage, not session.** A history that empties when the tab closes
|
|
* is not a history. Threads outlive the session, which is what makes
|
|
* "Yesterday" and "Older" mean anything.
|
|
* - **Written continuously, not on reset.** The archive is updated as the
|
|
* conversation grows rather than when it ends, so a thread is recoverable
|
|
* even if the tab is closed mid-answer.
|
|
*
|
|
* Nothing here is a second conversation store: the live thread is still the one
|
|
* the panel renders from. This holds copies, keyed by conversation, for the one
|
|
* question the panel could not previously answer — "what did I ask before?"
|
|
*/
|
|
|
|
const KEY = 'krow_assistant:history';
|
|
|
|
/** Threads older than this stop being useful and start being clutter. */
|
|
const MAX_AGE_DAYS = 60;
|
|
/** A hard cap, so a heavy user cannot fill the origin's storage quota. */
|
|
const MAX_RECORDS = 100;
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* The shape of a stored record.
|
|
*
|
|
* Bumped when a record gains fields, so a reader can tell an old record from a
|
|
* new one instead of guessing from which keys happen to be present.
|
|
*/
|
|
const SCHEMA = 2;
|
|
|
|
/**
|
|
* An older record, brought up to date on the way out.
|
|
*
|
|
* A conversation held before agents existed is still a conversation somebody
|
|
* had. It gets the new fields at their empty values and keeps everything it
|
|
* already had — in particular `messages` is passed through untouched, so
|
|
* nothing a reader wrote is rewritten by a schema change.
|
|
*
|
|
* Migrating on read rather than rewriting the store means a browser that never
|
|
* opens History again still loses nothing, and there is no migration pass that
|
|
* can fail halfway.
|
|
*/
|
|
function migrate(record) {
|
|
if (record.schema === SCHEMA) return record;
|
|
return {
|
|
...record,
|
|
schema: SCHEMA,
|
|
/* Absent, not unknown: these conversations genuinely had no agent, no
|
|
recorded skills and no feedback. */
|
|
agentId: record.agentId ?? null,
|
|
pageContext: record.pageContext ?? null,
|
|
skillsUsed: Array.isArray(record.skillsUsed) ? record.skillsUsed : [],
|
|
toolsUsed: Array.isArray(record.toolsUsed) ? record.toolsUsed : [],
|
|
knowledgeUsed: Array.isArray(record.knowledgeUsed) ? record.knowledgeUsed : [],
|
|
feedback: record.feedback ?? null,
|
|
};
|
|
}
|
|
|
|
/** Every archived conversation, newest first. Never throws. */
|
|
export function readHistory() {
|
|
try {
|
|
const raw = localStorage.getItem(KEY);
|
|
const list = raw ? JSON.parse(raw) : [];
|
|
if (!Array.isArray(list)) return [];
|
|
|
|
const cutoff = Date.now() - MAX_AGE_DAYS * DAY;
|
|
return list
|
|
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
|
|
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
|
|
.map(migrate)
|
|
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
|
} catch {
|
|
/* Corrupt or unavailable storage is an empty history, not an error the
|
|
reader has to deal with. */
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** The first thing the person asked, which is what the thread was about. */
|
|
function titleFor(messages) {
|
|
const first = messages.find((m) => m.role === 'user' && m.text);
|
|
const text = String(first?.text || '').trim();
|
|
if (!text) return 'Conversation';
|
|
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
|
}
|
|
|
|
/**
|
|
* Records this conversation, replacing its own earlier entry.
|
|
*
|
|
* Matched on `id`, so a thread being added to updates in place rather than
|
|
* appearing once per turn.
|
|
*/
|
|
export function saveConversation({
|
|
id, contextId, page, messages,
|
|
/**
|
|
* Who answered, where, and what it actually used.
|
|
*
|
|
* Recorded from what ran rather than from what was available: `skillsUsed`
|
|
* is the skill that answered a turn, not every skill the page offered. A
|
|
* record of what *could* have happened would make the insight figures
|
|
* describe the registry instead of the conversation.
|
|
*/
|
|
agentId = null, pageContext = null,
|
|
skillsUsed = [], toolsUsed = [], knowledgeUsed = [],
|
|
}) {
|
|
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
|
|
|
|
/* Feedback belongs to the conversation, not to the turn that triggered a
|
|
save, so an existing rating survives the thread growing. */
|
|
const existing = readHistory().find((r) => r.id === id) || null;
|
|
|
|
const record = {
|
|
schema: SCHEMA,
|
|
id,
|
|
contextId,
|
|
page: page || '',
|
|
agentId,
|
|
/* The *reduced* envelope — where the question was asked, never a copy of
|
|
what was on screen. Storing selections and computed figures would write
|
|
the dataset into localStorage a turn at a time. */
|
|
pageContext,
|
|
skillsUsed: [...new Set(skillsUsed.filter(Boolean))],
|
|
toolsUsed: [...new Set(toolsUsed.filter(Boolean))],
|
|
knowledgeUsed: [...new Set(knowledgeUsed.filter(Boolean))],
|
|
feedback: existing?.feedback ?? null,
|
|
title: titleFor(messages),
|
|
turns: messages.filter((m) => m.role === 'user').length,
|
|
updatedAt: new Date().toISOString(),
|
|
messages,
|
|
};
|
|
|
|
const next = [record, ...readHistory().filter((r) => r.id !== id)].slice(0, MAX_RECORDS);
|
|
|
|
try {
|
|
localStorage.setItem(KEY, JSON.stringify(next));
|
|
} catch {
|
|
/* Quota or private mode: the live thread is unaffected, only the archive
|
|
is. Dropping the oldest half is a better outcome than losing every
|
|
future write. */
|
|
try {
|
|
localStorage.setItem(KEY, JSON.stringify(next.slice(0, Math.floor(MAX_RECORDS / 4))));
|
|
} catch {
|
|
/* Storage is unusable. History is in-memory for this session only. */
|
|
}
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Records how a conversation was rated.
|
|
*
|
|
* Updates in place and never appends: rating a thread twice is a correction,
|
|
* not two opinions. Returns the refreshed list so a caller re-renders from one
|
|
* read rather than two.
|
|
*/
|
|
export function recordFeedback(id, feedback) {
|
|
const list = readHistory();
|
|
const at = list.findIndex((r) => r.id === id);
|
|
if (at === -1) return list;
|
|
|
|
const next = [...list];
|
|
next[at] = {
|
|
...next[at],
|
|
feedback: feedback
|
|
? {
|
|
rating: feedback.rating === 'up' ? 'up' : 'down',
|
|
note: String(feedback.note || '').trim() || null,
|
|
at: new Date().toISOString(),
|
|
}
|
|
/* Clearing is a real action — someone un-rating a thread should leave no
|
|
rating behind rather than a neutral one. */
|
|
: null,
|
|
};
|
|
|
|
try {
|
|
localStorage.setItem(KEY, JSON.stringify(next));
|
|
} catch {
|
|
/* The rating stays in memory for this session. */
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
/** Forgets one conversation. */
|
|
export function removeConversation(id) {
|
|
const next = readHistory().filter((r) => r.id !== id);
|
|
try {
|
|
localStorage.setItem(KEY, JSON.stringify(next));
|
|
} catch {
|
|
/* Ignore — the caller re-reads either way. */
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/** Forgets everything. */
|
|
export function clearHistory() {
|
|
try {
|
|
localStorage.removeItem(KEY);
|
|
} catch {
|
|
/* Ignore. */
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Conversations under a heading, in the order a person thinks about time.
|
|
*
|
|
* Empty groups are dropped rather than rendered as a heading with nothing
|
|
* under it.
|
|
*/
|
|
export function groupByRecency(records, now = new Date()) {
|
|
const startOfToday = new Date(now);
|
|
startOfToday.setHours(0, 0, 0, 0);
|
|
const startOfYesterday = new Date(startOfToday.getTime() - DAY);
|
|
const startOfWeek = new Date(startOfToday.getTime() - 7 * DAY);
|
|
|
|
const groups = [
|
|
{ id: 'today', label: 'Today', items: [] },
|
|
{ id: 'yesterday', label: 'Yesterday', items: [] },
|
|
{ id: 'week', label: 'Earlier this week', items: [] },
|
|
{ id: 'older', label: 'Older', items: [] },
|
|
];
|
|
|
|
for (const record of records) {
|
|
const at = new Date(record.updatedAt || 0).getTime();
|
|
if (at >= startOfToday.getTime()) groups[0].items.push(record);
|
|
else if (at >= startOfYesterday.getTime()) groups[1].items.push(record);
|
|
else if (at >= startOfWeek.getTime()) groups[2].items.push(record);
|
|
else groups[3].items.push(record);
|
|
}
|
|
|
|
return groups.filter((g) => g.items.length);
|
|
}
|
|
|
|
/** A new conversation id. Time-ordered, so ids sort the way threads do. */
|
|
export function newConversationId() {
|
|
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|