feat(assistant): implement spec 021 chat assistant flow with semantic contracts
This commit is contained in:
50
frontend/src/lib/api/assistant.js
Normal file
50
frontend/src/lib/api/assistant.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// [DEF:frontend.src.lib.api.assistant:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: assistant, api, client, chat, confirmation
|
||||
// @PURPOSE: API client wrapper for assistant chat, confirmation actions, and history retrieval.
|
||||
// @LAYER: Infra-API
|
||||
// @RELATION: DEPENDS_ON -> frontend.src.lib.api.api_module
|
||||
// @INVARIANT: All assistant requests must use requestApi wrapper (no native fetch).
|
||||
|
||||
import { requestApi } from '$lib/api.js';
|
||||
|
||||
// [DEF:sendAssistantMessage:Function]
|
||||
// @PURPOSE: Send a user message to assistant orchestrator endpoint.
|
||||
// @PRE: payload.message is a non-empty string.
|
||||
// @POST: Returns assistant response object with deterministic state.
|
||||
export function sendAssistantMessage(payload) {
|
||||
return requestApi('/assistant/messages', 'POST', payload);
|
||||
}
|
||||
// [/DEF:sendAssistantMessage:Function]
|
||||
|
||||
// [DEF:confirmAssistantOperation:Function]
|
||||
// @PURPOSE: Confirm a pending risky assistant operation.
|
||||
// @PRE: confirmationId references an existing pending token.
|
||||
// @POST: Returns execution response (started/success/failed).
|
||||
export function confirmAssistantOperation(confirmationId) {
|
||||
return requestApi(`/assistant/confirmations/${confirmationId}/confirm`, 'POST');
|
||||
}
|
||||
// [/DEF:confirmAssistantOperation:Function]
|
||||
|
||||
// [DEF:cancelAssistantOperation:Function]
|
||||
// @PURPOSE: Cancel a pending risky assistant operation.
|
||||
// @PRE: confirmationId references an existing pending token.
|
||||
// @POST: Operation is cancelled and cannot be executed by this token.
|
||||
export function cancelAssistantOperation(confirmationId) {
|
||||
return requestApi(`/assistant/confirmations/${confirmationId}/cancel`, 'POST');
|
||||
}
|
||||
// [/DEF:cancelAssistantOperation:Function]
|
||||
|
||||
// [DEF:getAssistantHistory:Function]
|
||||
// @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) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (conversationId) {
|
||||
params.append('conversation_id', conversationId);
|
||||
}
|
||||
return requestApi(`/assistant/history?${params.toString()}`, 'GET');
|
||||
}
|
||||
// [/DEF:getAssistantHistory:Function]
|
||||
// [/DEF:frontend.src.lib.api.assistant:Module]
|
||||
342
frontend/src/lib/components/assistant/AssistantChatPanel.svelte
Normal file
342
frontend/src/lib/components/assistant/AssistantChatPanel.svelte
Normal file
@@ -0,0 +1,342 @@
|
||||
<!-- [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="activity" 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] -->
|
||||
@@ -0,0 +1,87 @@
|
||||
// [DEF:frontend.src.lib.components.assistant.__tests__.assistant_chat_integration:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: assistant, integration-test, ux-contract, i18n
|
||||
// @PURPOSE: Contract-level integration checks for assistant chat panel implementation and localization wiring.
|
||||
// @LAYER: UI Tests
|
||||
// @RELATION: VERIFIES -> frontend/src/lib/components/assistant/AssistantChatPanel.svelte
|
||||
// @INVARIANT: Critical assistant UX states and action hooks remain present in component source.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const COMPONENT_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/components/assistant/AssistantChatPanel.svelte',
|
||||
);
|
||||
const EN_LOCALE_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/i18n/locales/en.json',
|
||||
);
|
||||
const RU_LOCALE_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/i18n/locales/ru.json',
|
||||
);
|
||||
|
||||
// [DEF:readJson:Function]
|
||||
// @PURPOSE: Read and parse JSON fixture file from disk.
|
||||
// @PRE: filePath points to existing UTF-8 JSON file.
|
||||
// @POST: Returns parsed object representation.
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
// [/DEF:readJson:Function]
|
||||
|
||||
// [DEF:assistant_chat_contract_tests:Function]
|
||||
// @PURPOSE: Validate assistant chat component contract and locale integration without DOM runtime dependency.
|
||||
// @PRE: Component and locale files exist in expected paths.
|
||||
// @POST: Contract checks guarantee assistant UI anchors and i18n wiring remain intact.
|
||||
describe('AssistantChatPanel integration contract', () => {
|
||||
it('contains semantic anchors and UX contract tags', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('<!-- [DEF' + ':AssistantChatPanel:Component] -->');
|
||||
expect(source).toContain('@TIER: CRITICAL');
|
||||
expect(source).toContain('@UX_STATE: LoadingHistory');
|
||||
expect(source).toContain('@UX_STATE: Sending');
|
||||
expect(source).toContain('@UX_STATE: Error');
|
||||
expect(source).toContain('@UX_FEEDBACK: Started operation surfaces task_id');
|
||||
expect(source).toContain('@UX_RECOVERY: User can retry command');
|
||||
expect(source).toContain('<!-- [/DEF' + ':AssistantChatPanel:Component] -->');
|
||||
});
|
||||
|
||||
it('keeps confirmation/task-tracking action hooks in place', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain("if (action.type === 'confirm' && message.confirmation_id)");
|
||||
expect(source).toContain("if (action.type === 'cancel' && message.confirmation_id)");
|
||||
expect(source).toContain("if (action.type === 'open_task' && action.target)");
|
||||
expect(source).toContain('openDrawerForTask(action.target)');
|
||||
expect(source).toContain("goto('/reports')");
|
||||
});
|
||||
|
||||
it('uses i18n bindings for assistant UI labels', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('$t.assistant?.title');
|
||||
expect(source).toContain('$t.assistant?.input_placeholder');
|
||||
expect(source).toContain('$t.assistant?.send');
|
||||
expect(source).toContain('$t.assistant?.states?.[message.state]');
|
||||
expect(source).toContain('$t.assistant?.open_task_drawer');
|
||||
});
|
||||
|
||||
it('provides assistant locale keys in both en and ru dictionaries', () => {
|
||||
const en = readJson(EN_LOCALE_PATH);
|
||||
const ru = readJson(RU_LOCALE_PATH);
|
||||
|
||||
expect(en.assistant.title).toBeTruthy();
|
||||
expect(en.assistant.send).toBeTruthy();
|
||||
expect(en.assistant.states.needs_confirmation).toBeTruthy();
|
||||
|
||||
expect(ru.assistant.title).toBeTruthy();
|
||||
expect(ru.assistant.send).toBeTruthy();
|
||||
expect(ru.assistant.states.needs_confirmation).toBeTruthy();
|
||||
});
|
||||
});
|
||||
// [/DEF:assistant_chat_contract_tests:Function]
|
||||
// [/DEF:frontend.src.lib.components.assistant.__tests__.assistant_chat_integration:Module]
|
||||
@@ -0,0 +1,50 @@
|
||||
// [DEF:frontend.src.lib.components.assistant.__tests__.assistant_confirmation_integration:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: assistant, confirmation, integration-test, ux
|
||||
// @PURPOSE: Validate confirm/cancel UX contract bindings in assistant chat panel source.
|
||||
// @LAYER: UI Tests
|
||||
// @RELATION: VERIFIES -> frontend/src/lib/components/assistant/AssistantChatPanel.svelte
|
||||
// @INVARIANT: Confirm/cancel action handling must remain explicit and confirmation-id bound.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const COMPONENT_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/components/assistant/AssistantChatPanel.svelte',
|
||||
);
|
||||
|
||||
// [DEF:assistant_confirmation_contract_tests:Function]
|
||||
// @PURPOSE: Assert that confirmation UX flow and API bindings are preserved in chat panel.
|
||||
// @PRE: Assistant panel source file exists and is readable.
|
||||
// @POST: Test guarantees explicit confirm/cancel guards and failed-action recovery path.
|
||||
describe('AssistantChatPanel confirmation integration contract', () => {
|
||||
it('contains confirmation action guards with confirmation_id checks', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain("if (action.type === 'confirm' && message.confirmation_id)");
|
||||
expect(source).toContain("if (action.type === 'cancel' && message.confirmation_id)");
|
||||
expect(source).toContain('confirmAssistantOperation(message.confirmation_id)');
|
||||
expect(source).toContain('cancelAssistantOperation(message.confirmation_id)');
|
||||
});
|
||||
|
||||
it('renders action buttons from assistant response payload', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('{#if message.actions?.length}');
|
||||
expect(source).toContain('{#each message.actions as action}');
|
||||
expect(source).toContain('{action.label}');
|
||||
expect(source).toContain('on:click={() => handleAction(action, message)}');
|
||||
});
|
||||
|
||||
it('keeps failed-action recovery response path', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain("response_id: `action-error-${Date.now()}`");
|
||||
expect(source).toContain("state: 'failed'");
|
||||
expect(source).toContain("text: err.message || 'Action failed'");
|
||||
});
|
||||
});
|
||||
// [/DEF:assistant_confirmation_contract_tests:Function]
|
||||
// [/DEF:frontend.src.lib.components.assistant.__tests__.assistant_confirmation_integration:Module]
|
||||
@@ -12,6 +12,8 @@
|
||||
* @UX_STATE: SearchFocused -> Search input expands
|
||||
* @UX_FEEDBACK: Activity badge shows count of running tasks
|
||||
* @UX_RECOVERY: Click outside closes dropdowns
|
||||
* @UX_TEST: SearchFocused -> {focus: search input, expected: focused style class applied}
|
||||
* @UX_TEST: ActivityClick -> {click: activity button, expected: task drawer opens}
|
||||
*/
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
@@ -25,6 +27,7 @@
|
||||
import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.js";
|
||||
import { t } from "$lib/i18n";
|
||||
import { auth } from "$lib/auth/store.js";
|
||||
import { toggleAssistantChat } from "$lib/stores/assistantChat.js";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -64,6 +67,10 @@
|
||||
dispatch("activityClick");
|
||||
}
|
||||
|
||||
function handleAssistantClick() {
|
||||
toggleAssistantChat();
|
||||
}
|
||||
|
||||
function handleSearchFocus() {
|
||||
isSearchFocused = true;
|
||||
}
|
||||
@@ -129,6 +136,16 @@
|
||||
|
||||
<!-- Nav Actions -->
|
||||
<div class="flex items-center gap-3 md:gap-4">
|
||||
<!-- Assistant -->
|
||||
<button
|
||||
class="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
|
||||
on:click={handleAssistantClick}
|
||||
aria-label={$t.assistant?.open || "Open assistant"}
|
||||
title={$t.assistant?.title || "AI Assistant"}
|
||||
>
|
||||
<Icon name="activity" size={22} />
|
||||
</button>
|
||||
|
||||
<!-- Activity Indicator -->
|
||||
<div
|
||||
class="relative cursor-pointer rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
|
||||
|
||||
@@ -221,6 +221,24 @@
|
||||
"cron_hint": "e.g., 0 0 * * * for daily at midnight",
|
||||
"footer_text": "Task continues running in background"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "AI Assistant",
|
||||
"open": "Open assistant",
|
||||
"close": "Close assistant",
|
||||
"send": "Send",
|
||||
"input_placeholder": "Type a command...",
|
||||
"loading_history": "Loading history...",
|
||||
"try_commands": "Try commands:",
|
||||
"open_task_drawer": "Open Task Drawer",
|
||||
"states": {
|
||||
"started": "Started",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"needs_confirmation": "Needs confirmation",
|
||||
"needs_clarification": "Needs clarification",
|
||||
"denied": "Denied"
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"management": "Connection Management",
|
||||
"add_new": "Add New Connection",
|
||||
@@ -339,4 +357,4 @@
|
||||
"select_role": "Select a role"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,24 @@
|
||||
"cron_hint": "например, 0 0 * * * для ежедневного запуска в полночь",
|
||||
"footer_text": "Задача продолжает работать в фоновом режиме"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "AI Ассистент",
|
||||
"open": "Открыть ассистента",
|
||||
"close": "Закрыть ассистента",
|
||||
"send": "Отправить",
|
||||
"input_placeholder": "Введите команду...",
|
||||
"loading_history": "Загрузка истории...",
|
||||
"try_commands": "Попробуйте команды:",
|
||||
"open_task_drawer": "Открыть Task Drawer",
|
||||
"states": {
|
||||
"started": "Запущено",
|
||||
"success": "Успешно",
|
||||
"failed": "Ошибка",
|
||||
"needs_confirmation": "Требует подтверждения",
|
||||
"needs_clarification": "Нужно уточнение",
|
||||
"denied": "Доступ запрещен"
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"management": "Управление подключениями",
|
||||
"add_new": "Добавить новое подключение",
|
||||
@@ -338,4 +356,4 @@
|
||||
"select_role": "Выберите роль"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
frontend/src/lib/stores/__tests__/assistantChat.test.js
Normal file
59
frontend/src/lib/stores/__tests__/assistantChat.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// [DEF:frontend.src.lib.stores.__tests__.assistantChat:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: test, store, assistant, toggle, conversation
|
||||
// @PURPOSE: Validate assistant chat store visibility and conversation binding transitions.
|
||||
// @LAYER: UI Tests
|
||||
// @RELATION: DEPENDS_ON -> assistantChatStore
|
||||
// @INVARIANT: Each test starts from default closed state.
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
assistantChatStore,
|
||||
toggleAssistantChat,
|
||||
openAssistantChat,
|
||||
closeAssistantChat,
|
||||
setAssistantConversationId,
|
||||
} from '../assistantChat.js';
|
||||
|
||||
// [DEF:assistantChatStore_tests:Function]
|
||||
// @TIER: STANDARD
|
||||
// @PURPOSE: Group store unit scenarios for assistant panel behavior.
|
||||
// @PRE: Store can be reset to baseline state in beforeEach hook.
|
||||
// @POST: Open/close/toggle/conversation transitions are validated.
|
||||
describe('assistantChatStore', () => {
|
||||
beforeEach(() => {
|
||||
assistantChatStore.set({
|
||||
isOpen: false,
|
||||
conversationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should open assistant panel', () => {
|
||||
openAssistantChat();
|
||||
const state = get(assistantChatStore);
|
||||
expect(state.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close assistant panel', () => {
|
||||
openAssistantChat();
|
||||
closeAssistantChat();
|
||||
const state = get(assistantChatStore);
|
||||
expect(state.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle assistant panel state', () => {
|
||||
toggleAssistantChat();
|
||||
expect(get(assistantChatStore).isOpen).toBe(true);
|
||||
toggleAssistantChat();
|
||||
expect(get(assistantChatStore).isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should set conversation id', () => {
|
||||
setAssistantConversationId('conv-123');
|
||||
const state = get(assistantChatStore);
|
||||
expect(state.conversationId).toBe('conv-123');
|
||||
});
|
||||
});
|
||||
// [/DEF:assistantChatStore_tests:Function]
|
||||
// [/DEF:frontend.src.lib.stores.__tests__.assistantChat:Module]
|
||||
71
frontend/src/lib/stores/assistantChat.js
Normal file
71
frontend/src/lib/stores/assistantChat.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// [DEF:assistantChat:Store]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: assistant, store, ui-state, conversation
|
||||
// @PURPOSE: Control assistant chat panel visibility and active conversation binding.
|
||||
// @LAYER: UI
|
||||
// @RELATION: BINDS_TO -> AssistantChatPanel
|
||||
// @INVARIANT: conversationId persists while panel toggles unless explicitly reset.
|
||||
//
|
||||
// @UX_STATE: Closed -> Panel hidden.
|
||||
// @UX_STATE: Open -> Panel visible and interactive.
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const initialState = {
|
||||
isOpen: false,
|
||||
conversationId: null,
|
||||
};
|
||||
|
||||
export const assistantChatStore = writable(initialState);
|
||||
|
||||
// [DEF:toggleAssistantChat:Function]
|
||||
// @PURPOSE: Toggle assistant panel visibility.
|
||||
// @PRE: Store is initialized.
|
||||
// @POST: isOpen value inverted.
|
||||
export function toggleAssistantChat() {
|
||||
assistantChatStore.update((state) => {
|
||||
const next = { ...state, isOpen: !state.isOpen };
|
||||
console.log(`[assistantChat][${next.isOpen ? 'Open' : 'Closed'}] toggleAssistantChat`);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// [/DEF:toggleAssistantChat:Function]
|
||||
|
||||
// [DEF:openAssistantChat:Function]
|
||||
// @PURPOSE: Open assistant panel.
|
||||
// @PRE: Store is initialized.
|
||||
// @POST: isOpen = true.
|
||||
export function openAssistantChat() {
|
||||
assistantChatStore.update((state) => {
|
||||
const next = { ...state, isOpen: true };
|
||||
console.log('[assistantChat][Open] openAssistantChat');
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// [/DEF:openAssistantChat:Function]
|
||||
|
||||
// [DEF:closeAssistantChat:Function]
|
||||
// @PURPOSE: Close assistant panel.
|
||||
// @PRE: Store is initialized.
|
||||
// @POST: isOpen = false.
|
||||
export function closeAssistantChat() {
|
||||
assistantChatStore.update((state) => {
|
||||
const next = { ...state, isOpen: false };
|
||||
console.log('[assistantChat][Closed] closeAssistantChat');
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// [/DEF:closeAssistantChat:Function]
|
||||
|
||||
// [DEF:setAssistantConversationId:Function]
|
||||
// @PURPOSE: Bind current conversation id in UI state.
|
||||
// @PRE: conversationId is string-like identifier.
|
||||
// @POST: store.conversationId updated.
|
||||
export function setAssistantConversationId(conversationId) {
|
||||
assistantChatStore.update((state) => {
|
||||
console.log('[assistantChat][ConversationBound] setAssistantConversationId');
|
||||
return { ...state, conversationId };
|
||||
});
|
||||
}
|
||||
// [/DEF:setAssistantConversationId:Function]
|
||||
// [/DEF:assistantChat:Store]
|
||||
@@ -32,6 +32,7 @@
|
||||
import Sidebar from '$lib/components/layout/Sidebar.svelte';
|
||||
import TopNavbar from '$lib/components/layout/TopNavbar.svelte';
|
||||
import TaskDrawer from '$lib/components/layout/TaskDrawer.svelte';
|
||||
import AssistantChatPanel from '$lib/components/assistant/AssistantChatPanel.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.js';
|
||||
|
||||
@@ -71,6 +72,7 @@
|
||||
|
||||
<!-- Global Task Drawer -->
|
||||
<TaskDrawer />
|
||||
<AssistantChatPanel />
|
||||
</ProtectedRoute>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user