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

343 lines
13 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,
} from '$lib/api/assistant.js';
let input = '';
let loading = false;
let loadingHistory = false;
let messages = [];
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() {
if (loadingHistory || !isOpen) return;
loadingHistory = true;
try {
const history = await getAssistantHistory(1, 50, conversationId);
messages = (history.items || []).map((msg) => ({
...msg,
actions: msg.actions || msg.metadata?.actions || [],
}));
if (history.conversation_id && history.conversation_id !== conversationId) {
setAssistantConversationId(history.conversation_id);
}
initialized = true;
console.log('[AssistantChatPanel][Coherence:OK] History loaded');
} catch (err) {
console.error('[AssistantChatPanel][Coherence:Failed] Failed to load history', err);
} finally {
loadingHistory = false;
}
}
// [/DEF:loadHistory:Function]
$: if (isOpen && !initialized) {
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) {
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) {
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]
// [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() {
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);
} 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: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) {
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) {
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) {
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]
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="flex-1 space-y-3 overflow-y-auto p-4">
{#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 border-slate-300 px-2.5 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-100"
on:click={() => handleAction(action, message)}
>
{action.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
{/each}
</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] -->