feat(assistant): add conversations list, infinite history scroll, and archived tab
This commit is contained in:
@@ -39,12 +39,40 @@ export function cancelAssistantOperation(confirmationId) {
|
||||
// @PURPOSE: Retrieve paginated assistant conversation history.
|
||||
// @PRE: page/pageSize are positive integers.
|
||||
// @POST: Returns a paginated payload with history items.
|
||||
export function getAssistantHistory(page = 1, pageSize = 20, conversationId = null) {
|
||||
export function getAssistantHistory(page = 1, pageSize = 20, conversationId = null, fromLatest = false) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (conversationId) {
|
||||
params.append('conversation_id', conversationId);
|
||||
}
|
||||
if (fromLatest) {
|
||||
params.append('from_latest', 'true');
|
||||
}
|
||||
return requestApi(`/assistant/history?${params.toString()}`, 'GET');
|
||||
}
|
||||
// [/DEF:getAssistantHistory:Function]
|
||||
|
||||
// [DEF:getAssistantConversations:Function]
|
||||
// @PURPOSE: Retrieve paginated conversation list for assistant sidebar/history switcher.
|
||||
// @PRE: page/pageSize are positive integers.
|
||||
// @POST: Returns paginated conversation summaries.
|
||||
export function getAssistantConversations(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
includeArchived = false,
|
||||
search = '',
|
||||
archivedOnly = false,
|
||||
) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (includeArchived) {
|
||||
params.append('include_archived', 'true');
|
||||
}
|
||||
if (archivedOnly) {
|
||||
params.append('archived_only', 'true');
|
||||
}
|
||||
if (search?.trim()) {
|
||||
params.append('search', search.trim());
|
||||
}
|
||||
return requestApi(`/assistant/conversations?${params.toString()}`, 'GET');
|
||||
}
|
||||
// [/DEF:getAssistantConversations:Function]
|
||||
// [/DEF:frontend.src.lib.api.assistant:Module]
|
||||
|
||||
@@ -38,12 +38,27 @@
|
||||
confirmAssistantOperation,
|
||||
cancelAssistantOperation,
|
||||
getAssistantHistory,
|
||||
getAssistantConversations,
|
||||
} from '$lib/api/assistant.js';
|
||||
|
||||
const HISTORY_PAGE_SIZE = 30;
|
||||
const CONVERSATIONS_PAGE_SIZE = 20;
|
||||
|
||||
let input = '';
|
||||
let loading = false;
|
||||
let loadingHistory = false;
|
||||
let loadingMoreHistory = false;
|
||||
let loadingConversations = false;
|
||||
let messages = [];
|
||||
let conversations = [];
|
||||
let conversationFilter = 'active';
|
||||
let activeConversationsTotal = 0;
|
||||
let archivedConversationsTotal = 0;
|
||||
let historyPage = 1;
|
||||
let historyHasNext = false;
|
||||
let conversationsPage = 1;
|
||||
let conversationsHasNext = false;
|
||||
let historyViewport = null;
|
||||
let initialized = false;
|
||||
|
||||
$: isOpen = $assistantChatStore?.isOpen || false;
|
||||
@@ -60,11 +75,13 @@
|
||||
if (loadingHistory || !isOpen) return;
|
||||
loadingHistory = true;
|
||||
try {
|
||||
const history = await getAssistantHistory(1, 50, conversationId);
|
||||
const history = await getAssistantHistory(1, HISTORY_PAGE_SIZE, conversationId, true);
|
||||
messages = (history.items || []).map((msg) => ({
|
||||
...msg,
|
||||
actions: msg.actions || msg.metadata?.actions || [],
|
||||
}));
|
||||
historyPage = 1;
|
||||
historyHasNext = Boolean(history.has_next);
|
||||
if (history.conversation_id && history.conversation_id !== conversationId) {
|
||||
setAssistantConversationId(history.conversation_id);
|
||||
}
|
||||
@@ -78,10 +95,82 @@
|
||||
}
|
||||
// [/DEF:loadHistory:Function]
|
||||
|
||||
// [DEF:loadConversations:Function]
|
||||
/**
|
||||
* @PURPOSE: Load paginated conversation summaries for quick switching UI.
|
||||
* @PRE: Panel is open and request not already running.
|
||||
* @POST: conversations list refreshed or appended based on page.
|
||||
*/
|
||||
async function loadConversations(reset = false) {
|
||||
if (loadingConversations || !isOpen) return;
|
||||
loadingConversations = true;
|
||||
try {
|
||||
const page = reset ? 1 : conversationsPage + 1;
|
||||
const includeArchived = conversationFilter === 'archived';
|
||||
const archivedOnly = conversationFilter === 'archived';
|
||||
const response = await getAssistantConversations(
|
||||
page,
|
||||
CONVERSATIONS_PAGE_SIZE,
|
||||
includeArchived,
|
||||
'',
|
||||
archivedOnly,
|
||||
);
|
||||
const rows = response.items || [];
|
||||
conversations = reset ? rows : [...conversations, ...rows];
|
||||
conversationsPage = page;
|
||||
conversationsHasNext = Boolean(response.has_next);
|
||||
activeConversationsTotal = response.active_total ?? activeConversationsTotal;
|
||||
archivedConversationsTotal = response.archived_total ?? archivedConversationsTotal;
|
||||
} catch (err) {
|
||||
console.error('[AssistantChatPanel][Coherence:Failed] Failed to load conversations', err);
|
||||
} finally {
|
||||
loadingConversations = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:loadConversations:Function]
|
||||
|
||||
// [DEF:loadOlderMessages:Function]
|
||||
/**
|
||||
* @PURPOSE: Lazy-load older messages for active conversation when user scrolls to top.
|
||||
* @PRE: History has next page and active conversation is known.
|
||||
* @POST: Older messages are prepended while preserving order.
|
||||
*/
|
||||
async function loadOlderMessages() {
|
||||
if (loadingMoreHistory || loadingHistory || !historyHasNext || !conversationId) return;
|
||||
loadingMoreHistory = true;
|
||||
try {
|
||||
const nextPage = historyPage + 1;
|
||||
const history = await getAssistantHistory(nextPage, HISTORY_PAGE_SIZE, conversationId, true);
|
||||
const chunk = (history.items || []).map((msg) => ({
|
||||
...msg,
|
||||
actions: msg.actions || msg.metadata?.actions || [],
|
||||
}));
|
||||
const existingIds = new Set(messages.map((m) => m.message_id));
|
||||
const uniqueChunk = chunk.filter((m) => !existingIds.has(m.message_id));
|
||||
messages = [...uniqueChunk, ...messages];
|
||||
historyPage = nextPage;
|
||||
historyHasNext = Boolean(history.has_next);
|
||||
} catch (err) {
|
||||
console.error('[AssistantChatPanel][Coherence:Failed] Failed to load older messages', err);
|
||||
} finally {
|
||||
loadingMoreHistory = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:loadOlderMessages:Function]
|
||||
|
||||
$: if (isOpen && !initialized) {
|
||||
loadConversations(true);
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
$: if (isOpen && initialized && conversationId) {
|
||||
// Re-load only when user switched to another conversation.
|
||||
const currentFirstConversationId = messages.length ? messages[0].conversation_id : conversationId;
|
||||
if (currentFirstConversationId !== conversationId) {
|
||||
loadHistory();
|
||||
}
|
||||
}
|
||||
|
||||
// [DEF:appendLocalUserMessage:Function]
|
||||
/**
|
||||
* @PURPOSE: Add optimistic local user message before backend response.
|
||||
@@ -124,6 +213,29 @@
|
||||
}
|
||||
// [/DEF:appendAssistantResponse:Function]
|
||||
|
||||
function buildConversationTitle(conversation) {
|
||||
if (conversation?.title?.trim()) return conversation.title.trim();
|
||||
if (!conversation?.conversation_id) return 'Conversation';
|
||||
return `Conversation ${conversation.conversation_id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function setConversationFilter(filter) {
|
||||
if (filter !== 'active' && filter !== 'archived') return;
|
||||
if (conversationFilter === filter) return;
|
||||
conversationFilter = filter;
|
||||
conversations = [];
|
||||
conversationsPage = 1;
|
||||
conversationsHasNext = false;
|
||||
loadConversations(true);
|
||||
}
|
||||
|
||||
function formatConversationTime(iso) {
|
||||
if (!iso) return '';
|
||||
const dt = new Date(iso);
|
||||
if (Number.isNaN(dt.getTime())) return '';
|
||||
return dt.toLocaleString();
|
||||
}
|
||||
|
||||
// [DEF:handleSend:Function]
|
||||
/**
|
||||
* @PURPOSE: Submit user command to assistant orchestration API.
|
||||
@@ -150,6 +262,7 @@
|
||||
}
|
||||
|
||||
appendAssistantResponse(response);
|
||||
await loadConversations(true);
|
||||
} catch (err) {
|
||||
appendAssistantResponse({
|
||||
response_id: `error-${Date.now()}`,
|
||||
@@ -164,6 +277,40 @@
|
||||
}
|
||||
// [/DEF:handleSend:Function]
|
||||
|
||||
// [DEF:selectConversation:Function]
|
||||
/**
|
||||
* @PURPOSE: Switch active chat context to selected conversation item.
|
||||
* @PRE: conversation carries valid conversation_id.
|
||||
* @POST: conversationId updated and history reloaded.
|
||||
*/
|
||||
async function selectConversation(conversation) {
|
||||
if (!conversation?.conversation_id) return;
|
||||
if (conversation.conversation_id === conversationId) return;
|
||||
setAssistantConversationId(conversation.conversation_id);
|
||||
initialized = false;
|
||||
await loadHistory();
|
||||
}
|
||||
// [/DEF:selectConversation:Function]
|
||||
|
||||
// [DEF:startNewConversation:Function]
|
||||
/**
|
||||
* @PURPOSE: Create local empty chat context that will be persisted on first message.
|
||||
* @PRE: Panel is open.
|
||||
* @POST: Messages reset and new conversation id bound.
|
||||
*/
|
||||
function startNewConversation() {
|
||||
const newId =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `conv-${Date.now()}`;
|
||||
setAssistantConversationId(newId);
|
||||
messages = [];
|
||||
historyPage = 1;
|
||||
historyHasNext = false;
|
||||
initialized = true;
|
||||
}
|
||||
// [/DEF:startNewConversation:Function]
|
||||
|
||||
// [DEF:handleAction:Function]
|
||||
/**
|
||||
* @PURPOSE: Execute assistant action button behavior (open task/reports, confirm, cancel).
|
||||
@@ -235,6 +382,21 @@
|
||||
}
|
||||
// [/DEF:stateClass:Function]
|
||||
|
||||
// [DEF:handleHistoryScroll:Function]
|
||||
/**
|
||||
* @PURPOSE: Trigger lazy history fetch when user scroll reaches top boundary.
|
||||
* @PRE: Scroll event emitted by history viewport container.
|
||||
* @POST: loadOlderMessages called when boundary and more pages available.
|
||||
*/
|
||||
function handleHistoryScroll(event) {
|
||||
const el = event.currentTarget;
|
||||
if (!el || typeof el.scrollTop !== 'number') return;
|
||||
if (el.scrollTop <= 16) {
|
||||
loadOlderMessages();
|
||||
}
|
||||
}
|
||||
// [/DEF:handleHistoryScroll:Function]
|
||||
|
||||
onMount(() => {
|
||||
initialized = false;
|
||||
});
|
||||
@@ -259,7 +421,59 @@
|
||||
</div>
|
||||
|
||||
<div class="flex h-[calc(100%-56px)] flex-col">
|
||||
<div class="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
<div class="border-b border-slate-200 px-3 py-2">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">Conversations</span>
|
||||
<button
|
||||
class="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
on:click={startNewConversation}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-2 flex items-center gap-1">
|
||||
<button
|
||||
class="rounded-md border px-2 py-1 text-[11px] font-medium transition {conversationFilter === 'active' ? 'border-sky-300 bg-sky-50 text-sky-900' : 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100'}"
|
||||
on:click={() => setConversationFilter('active')}
|
||||
>
|
||||
Active ({activeConversationsTotal})
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border px-2 py-1 text-[11px] font-medium transition {conversationFilter === 'archived' ? 'border-sky-300 bg-sky-50 text-sky-900' : 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100'}"
|
||||
on:click={() => setConversationFilter('archived')}
|
||||
>
|
||||
Archived ({archivedConversationsTotal})
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto pb-1">
|
||||
{#each conversations as convo (convo.conversation_id)}
|
||||
<button
|
||||
class="min-w-[140px] max-w-[220px] rounded-lg border px-2.5 py-1.5 text-left text-xs transition {convo.conversation_id === conversationId ? 'border-sky-300 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'}"
|
||||
on:click={() => selectConversation(convo)}
|
||||
title={formatConversationTime(convo.updated_at)}
|
||||
>
|
||||
<div class="truncate font-semibold">{buildConversationTitle(convo)}</div>
|
||||
<div class="truncate text-[10px] text-slate-500">{convo.last_message || ''}</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if loadingConversations}
|
||||
<div class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs text-slate-500">...</div>
|
||||
{/if}
|
||||
{#if conversationsHasNext}
|
||||
<button
|
||||
class="rounded-lg border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
on:click={() => loadConversations(false)}
|
||||
>
|
||||
More
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-3 overflow-y-auto p-4" bind:this={historyViewport} on:scroll={handleHistoryScroll}>
|
||||
{#if loadingMoreHistory}
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center text-xs text-slate-500">Loading older messages...</div>
|
||||
{/if}
|
||||
{#if loadingHistory}
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">{$t.assistant?.loading_history || 'Loading history...'}</div>
|
||||
{:else if messages.length === 0}
|
||||
|
||||
Reference in New Issue
Block a user