Files
ss-tools/frontend/src/lib/components/assistant/AssistantChatPanel.svelte
2026-02-25 10:34:30 +03:00

746 lines
26 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- [DEF:AssistantChatPanel:Component] -->
<script>
/**
* @TIER: CRITICAL
* @PURPOSE: Slide-out assistant chat panel for natural language command execution and task tracking.
* @LAYER: UI
* @RELATION: BINDS_TO -> assistantChatStore
* @RELATION: CALLS -> frontend.src.lib.api.assistant
* @RELATION: DISPATCHES -> taskDrawerStore
* @SEMANTICS: assistant-chat, confirmation, long-running-task, progress-tracking
* @INVARIANT: User commands and assistant responses are appended in chronological order.
* @INVARIANT: Risky operations are executed only through explicit confirm action.
*
* @UX_STATE: Closed -> Panel is hidden.
* @UX_STATE: LoadingHistory -> Existing conversation history is loading.
* @UX_STATE: Idle -> Input is available and no request in progress.
* @UX_STATE: Sending -> Input locked while request is pending.
* @UX_STATE: Error -> Failed action rendered as assistant failed message.
* @UX_FEEDBACK: Started operation surfaces task_id and quick action to open task drawer.
* @UX_RECOVERY: User can retry command or action from input and action buttons.
* @UX_TEST: LoadingHistory -> {openPanel: true, expected: loading block visible}
* @UX_TEST: Sending -> {sendMessage: "branch", expected: send button disabled}
* @UX_TEST: NeedsConfirmation -> {click: confirm action, expected: started response with task_id}
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { t } from "$lib/i18n";
import Icon from "$lib/ui/Icon.svelte";
import { openDrawerForTask } from "$lib/stores/taskDrawer.js";
import {
assistantChatStore,
closeAssistantChat,
setAssistantConversationId,
} from "$lib/stores/assistantChat.js";
import {
sendAssistantMessage,
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 historyLoadVersion = 0;
let conversationsPage = 1;
let conversationsHasNext = false;
let historyViewport = null;
let initialized = false;
$: isOpen = $assistantChatStore?.isOpen || false;
$: conversationId = $assistantChatStore?.conversationId || null;
// [DEF:loadHistory:Function]
/**
* @PURPOSE: Load current conversation history when panel becomes visible.
* @PRE: Panel is open and history request is not already running.
* @POST: messages are populated from persisted history and conversation id is synchronized.
* @SIDE_EFFECT: Performs API call to assistant history endpoint.
*/
async function loadHistory(targetConversationId = conversationId) {
if (loadingHistory || !isOpen) return;
const requestVersion = ++historyLoadVersion;
loadingHistory = true;
try {
const history = await getAssistantHistory(
1,
HISTORY_PAGE_SIZE,
targetConversationId,
true,
);
if (requestVersion !== historyLoadVersion) {
return;
}
messages = (history.items || []).map((msg) => ({
...msg,
actions: msg.actions || msg.metadata?.actions || [],
}));
historyPage = 1;
historyHasNext = Boolean(history.has_next);
if (
!targetConversationId &&
history.conversation_id &&
history.conversation_id !== conversationId
) {
setAssistantConversationId(history.conversation_id);
}
initialized = true;
// prettier-ignore
console.log("[AssistantChatPanel][history][loadHistory:SUCCESS] History loaded");
} catch (err) {
console.error(
"[AssistantChatPanel][history][loadHistory:FAILED] Failed to load history",
err,
);
} finally {
loadingHistory = false;
}
}
// [/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;
// prettier-ignore
console.log("[AssistantChatPanel][conversations][loadConversations:SUCCESS]");
} catch (err) {
console.error(
"[AssistantChatPanel][conversations][loadConversations:FAILED]",
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);
console.log("[AssistantChatPanel][history][loadOlderMessages:SUCCESS]");
} catch (err) {
console.error(
"[AssistantChatPanel][history][loadOlderMessages:FAILED]",
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.
* @PRE: text is non-empty command text.
* @POST: user message appears at the end of messages list.
*/
function appendLocalUserMessage(text) {
console.log("[AssistantChatPanel][message][appendLocalUserMessage][START]");
messages = [
...messages,
{
message_id: `local-${Date.now()}`,
role: "user",
text,
created_at: new Date().toISOString(),
},
];
}
// [/DEF:appendLocalUserMessage:Function]
// [DEF:appendAssistantResponse:Function]
/**
* @PURPOSE: Normalize and append assistant response payload to chat list.
* @PRE: response follows assistant message response contract.
* @POST: assistant message appended with state/task/actions metadata.
*/
function appendAssistantResponse(response) {
// prettier-ignore
console.log("[AssistantChatPanel][message][appendAssistantResponse][START]");
messages = [
...messages,
{
message_id: response.response_id,
role: "assistant",
text: response.text,
state: response.state,
task_id: response.task_id || null,
confirmation_id: response.confirmation_id || null,
actions: response.actions || [],
created_at: response.created_at,
},
];
}
// [/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.
* @PRE: input contains a non-empty command and current request is not loading.
* @POST: assistant response is rendered and conversation id is persisted in store.
* @SIDE_EFFECT: Triggers backend command execution pipeline.
*/
async function handleSend() {
console.log("[AssistantChatPanel][message][handleSend][START]");
const text = input.trim();
if (!text || loading) return;
appendLocalUserMessage(text);
input = "";
loading = true;
try {
const response = await sendAssistantMessage({
conversation_id: conversationId,
message: text,
});
if (response.conversation_id) {
setAssistantConversationId(response.conversation_id);
}
appendAssistantResponse(response);
await loadConversations(true);
} catch (err) {
appendAssistantResponse({
response_id: `error-${Date.now()}`,
text: err.message || "Assistant request failed",
state: "failed",
created_at: new Date().toISOString(),
actions: [],
});
} finally {
loading = false;
}
}
// [/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) {
// prettier-ignore
console.log("[AssistantChatPanel][conversation][selectConversation][START]");
if (!conversation?.conversation_id) return;
if (conversation.conversation_id === conversationId) return;
// Invalidate any in-flight history request to avoid stale conversation overwrite.
historyLoadVersion += 1;
setAssistantConversationId(conversation.conversation_id);
messages = [];
historyPage = 1;
historyHasNext = false;
await loadHistory(conversation.conversation_id);
}
// [/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() {
// prettier-ignore
console.log("[AssistantChatPanel][conversation][startNewConversation][START]");
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).
* @PRE: action object is produced by assistant response contract.
* @POST: UI navigation or follow-up assistant response is appended.
* @SIDE_EFFECT: May navigate routes or call confirm/cancel API endpoints.
*/
async function handleAction(action, message) {
console.log("[AssistantChatPanel][action][handleAction][START]");
try {
if (action.type === "open_task" && action.target) {
openDrawerForTask(action.target);
return;
}
if (action.type === "open_reports") {
goto("/reports");
return;
}
if (action.type === "confirm" && message.confirmation_id) {
const response = await confirmAssistantOperation(
message.confirmation_id,
);
appendAssistantResponse(response);
return;
}
if (action.type === "cancel" && message.confirmation_id) {
const response = await cancelAssistantOperation(
message.confirmation_id,
);
appendAssistantResponse(response);
}
} catch (err) {
appendAssistantResponse({
response_id: `action-error-${Date.now()}`,
text: err.message || "Action failed",
state: "failed",
created_at: new Date().toISOString(),
actions: [],
});
}
}
// [/DEF:handleAction:Function]
// [DEF:handleKeydown:Function]
/**
* @PURPOSE: Submit command by Enter while preserving multiline input with Shift+Enter.
* @PRE: Keyboard event received from chat input.
* @POST: handleSend is invoked when Enter is pressed without shift modifier.
*/
function handleKeydown(event) {
console.log("[AssistantChatPanel][input][handleKeydown][START]");
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}
// [/DEF:handleKeydown:Function]
// [DEF:stateClass:Function]
/**
* @PURPOSE: Map assistant state to visual badge style class.
* @PRE: state is a nullable assistant state string.
* @POST: Tailwind class string returned for badge rendering.
*/
function stateClass(state) {
console.log("[AssistantChatPanel][ui][stateClass][START]");
if (state === "started") return "bg-sky-100 text-sky-700 border-sky-200";
if (state === "success")
return "bg-emerald-100 text-emerald-700 border-emerald-200";
if (state === "needs_confirmation")
return "bg-amber-100 text-amber-700 border-amber-200";
if (state === "denied" || state === "failed")
return "bg-rose-100 text-rose-700 border-rose-200";
if (state === "needs_clarification")
return "bg-violet-100 text-violet-700 border-violet-200";
return "bg-slate-100 text-slate-700 border-slate-200";
}
// [/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) {
console.log("[AssistantChatPanel][scroll][handleHistoryScroll][START]");
const el = event.currentTarget;
if (!el || typeof el.scrollTop !== "number") return;
if (el.scrollTop <= 16) {
loadOlderMessages();
}
}
// [/DEF:handleHistoryScroll:Function]
onMount(() => {
initialized = false;
});
</script>
{#if isOpen}
<div
class="fixed inset-0 z-[70] bg-slate-900/30"
on:click={closeAssistantChat}
aria-hidden="true"
></div>
<aside
class="fixed right-0 top-0 z-[71] h-full w-full max-w-md border-l border-slate-200 bg-white shadow-2xl"
>
<div
class="flex h-14 items-center justify-between border-b border-slate-200 px-4"
>
<div class="flex items-center gap-2 text-slate-800">
<Icon name="clipboard" size={18} />
<h2 class="text-sm font-semibold">
{$t.assistant?.title || "AI Assistant"}
</h2>
</div>
<button
class="rounded-md p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"
on:click={closeAssistantChat}
aria-label={$t.assistant?.close || "Close assistant"}
>
<Icon name="close" size={18} />
</button>
</div>
<div class="flex h-[calc(100%-56px)] flex-col">
<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}
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600"
>
{$t.assistant?.try_commands || "Try commands:"}
<div class="mt-2 space-y-1 text-xs">
<div>• сделай ветку feature/new-dashboard для дашборда 42</div>
<div>• запусти миграцию с dev на prod для дашборда 42</div>
<div>• проверь статус задачи task-123</div>
</div>
</div>
{/if}
{#each messages as message (message.message_id)}
<div class={message.role === "user" ? "ml-8" : "mr-8"}>
<div
class="rounded-xl border p-3 {message.role === 'user'
? 'border-sky-200 bg-sky-50'
: 'border-slate-200 bg-white'}"
>
<div class="mb-1 flex items-center justify-between gap-2">
<span
class="text-[11px] font-semibold uppercase tracking-wide text-slate-500"
>
{message.role === "user" ? "You" : "Assistant"}
</span>
{#if message.state}
<span
class="rounded-md border px-2 py-0.5 text-[10px] font-medium {stateClass(
message.state,
)}"
>
{$t.assistant?.states?.[message.state] || message.state}
</span>
{/if}
</div>
<div class="whitespace-pre-wrap text-sm text-slate-800">
{message.text}
</div>
{#if message.task_id}
<div class="mt-2 flex items-center gap-2">
<span
class="rounded border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs text-slate-700"
>task_id: {message.task_id}</span
>
<button
class="text-xs font-medium text-sky-700 hover:text-sky-900"
on:click={() => openDrawerForTask(message.task_id)}
>
{$t.assistant?.open_task_drawer || "Open Task Drawer"}
</button>
</div>
{/if}
{#if message.actions?.length}
<div class="mt-3 flex flex-wrap gap-2">
{#each message.actions as action}
<button
class="rounded-md border px-3 py-1.5 text-xs font-semibold transition
{action.type === 'confirm'
? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: action.type === 'cancel'
? 'border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100'
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100'}"
on:click={() => handleAction(action, message)}
>
{action.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
{/each}
{#if loading}
<div class="mr-8">
<div class="rounded-xl border border-slate-200 bg-white p-3">
<div class="mb-1 flex items-center justify-between gap-2">
<span
class="text-[11px] font-semibold uppercase tracking-wide text-slate-500"
>
Assistant
</span>
</div>
<div class="flex items-center gap-2 text-sm text-slate-700">
<span>{$t.assistant?.thinking || "Думаю"}</span>
<span class="thinking-dots" aria-hidden="true">
<span></span><span></span><span></span>
</span>
</div>
</div>
</div>
{/if}
</div>
<div class="border-t border-slate-200 p-3">
<div class="flex items-end gap-2">
<textarea
bind:value={input}
rows="2"
placeholder={$t.assistant?.input_placeholder || "Type a command..."}
class="min-h-[52px] w-full resize-y rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
on:keydown={handleKeydown}
></textarea>
<button
class="rounded-lg bg-sky-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-60"
on:click={handleSend}
disabled={loading || !input.trim()}
>
{loading ? "..." : $t.assistant?.send || "Send"}
</button>
</div>
</div>
</div>
</aside>
{/if}
<!-- [/DEF:AssistantChatPanel:Component] -->
<style>
.thinking-dots {
display: inline-flex;
gap: 0.25rem;
align-items: center;
}
.thinking-dots span {
width: 0.35rem;
height: 0.35rem;
border-radius: 9999px;
background: #0ea5e9;
opacity: 0.35;
animation: assistant-thinking-bounce 0.9s infinite ease-in-out;
}
.thinking-dots span:nth-child(2) {
animation-delay: 0.12s;
}
.thinking-dots span:nth-child(3) {
animation-delay: 0.24s;
}
@keyframes assistant-thinking-bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-0.2rem);
opacity: 1;
}
}
</style>