semantic update

This commit is contained in:
2026-02-24 21:08:12 +03:00
parent 7a12ed0931
commit 95ae9c6af1
32 changed files with 60376 additions and 59911 deletions

View File

@@ -23,35 +23,35 @@
* @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 { 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';
} from "$lib/stores/assistantChat.js";
import {
sendAssistantMessage,
confirmAssistantOperation,
cancelAssistantOperation,
getAssistantHistory,
getAssistantConversations,
} from '$lib/api/assistant.js';
} from "$lib/api/assistant.js";
const HISTORY_PAGE_SIZE = 30;
const CONVERSATIONS_PAGE_SIZE = 20;
let input = '';
let input = "";
let loading = false;
let loadingHistory = false;
let loadingMoreHistory = false;
let loadingConversations = false;
let messages = [];
let conversations = [];
let conversationFilter = 'active';
let conversationFilter = "active";
let activeConversationsTotal = 0;
let archivedConversationsTotal = 0;
let historyPage = 1;
@@ -77,7 +77,12 @@
const requestVersion = ++historyLoadVersion;
loadingHistory = true;
try {
const history = await getAssistantHistory(1, HISTORY_PAGE_SIZE, targetConversationId, true);
const history = await getAssistantHistory(
1,
HISTORY_PAGE_SIZE,
targetConversationId,
true,
);
if (requestVersion !== historyLoadVersion) {
return;
}
@@ -87,13 +92,21 @@
}));
historyPage = 1;
historyHasNext = Boolean(history.has_next);
if (!targetConversationId && history.conversation_id && history.conversation_id !== conversationId) {
if (
!targetConversationId &&
history.conversation_id &&
history.conversation_id !== conversationId
) {
setAssistantConversationId(history.conversation_id);
}
initialized = true;
console.log('[AssistantChatPanel][Coherence:OK] History loaded');
// prettier-ignore
console.log("[AssistantChatPanel][history][loadHistory:SUCCESS] History loaded");
} catch (err) {
console.error('[AssistantChatPanel][Coherence:Failed] Failed to load history', err);
console.error(
"[AssistantChatPanel][history][loadHistory:FAILED] Failed to load history",
err,
);
} finally {
loadingHistory = false;
}
@@ -111,23 +124,30 @@
loadingConversations = true;
try {
const page = reset ? 1 : conversationsPage + 1;
const includeArchived = conversationFilter === 'archived';
const archivedOnly = conversationFilter === 'archived';
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;
activeConversationsTotal =
response.active_total ?? activeConversationsTotal;
archivedConversationsTotal =
response.archived_total ?? archivedConversationsTotal;
// prettier-ignore
console.log("[AssistantChatPanel][conversations][loadConversations:SUCCESS]");
} catch (err) {
console.error('[AssistantChatPanel][Coherence:Failed] Failed to load conversations', err);
console.error(
"[AssistantChatPanel][conversations][loadConversations:FAILED]",
err,
);
} finally {
loadingConversations = false;
}
@@ -141,11 +161,22 @@
* @POST: Older messages are prepended while preserving order.
*/
async function loadOlderMessages() {
if (loadingMoreHistory || loadingHistory || !historyHasNext || !conversationId) return;
if (
loadingMoreHistory ||
loadingHistory ||
!historyHasNext ||
!conversationId
)
return;
loadingMoreHistory = true;
try {
const nextPage = historyPage + 1;
const history = await getAssistantHistory(nextPage, HISTORY_PAGE_SIZE, conversationId, true);
const history = await getAssistantHistory(
nextPage,
HISTORY_PAGE_SIZE,
conversationId,
true,
);
const chunk = (history.items || []).map((msg) => ({
...msg,
actions: msg.actions || msg.metadata?.actions || [],
@@ -155,8 +186,12 @@
messages = [...uniqueChunk, ...messages];
historyPage = nextPage;
historyHasNext = Boolean(history.has_next);
console.log("[AssistantChatPanel][history][loadOlderMessages:SUCCESS]");
} catch (err) {
console.error('[AssistantChatPanel][Coherence:Failed] Failed to load older messages', err);
console.error(
"[AssistantChatPanel][history][loadOlderMessages:FAILED]",
err,
);
} finally {
loadingMoreHistory = false;
}
@@ -170,7 +205,9 @@
$: if (isOpen && initialized && conversationId) {
// Re-load only when user switched to another conversation.
const currentFirstConversationId = messages.length ? messages[0].conversation_id : conversationId;
const currentFirstConversationId = messages.length
? messages[0].conversation_id
: conversationId;
if (currentFirstConversationId !== conversationId) {
loadHistory();
}
@@ -183,11 +220,12 @@
* @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',
role: "user",
text,
created_at: new Date().toISOString(),
},
@@ -202,11 +240,13 @@
* @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',
role: "assistant",
text: response.text,
state: response.state,
task_id: response.task_id || null,
@@ -220,12 +260,12 @@
function buildConversationTitle(conversation) {
if (conversation?.title?.trim()) return conversation.title.trim();
if (!conversation?.conversation_id) return 'Conversation';
if (!conversation?.conversation_id) return "Conversation";
return `Conversation ${conversation.conversation_id.slice(0, 8)}`;
}
function setConversationFilter(filter) {
if (filter !== 'active' && filter !== 'archived') return;
if (filter !== "active" && filter !== "archived") return;
if (conversationFilter === filter) return;
conversationFilter = filter;
conversations = [];
@@ -235,9 +275,9 @@
}
function formatConversationTime(iso) {
if (!iso) return '';
if (!iso) return "";
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return '';
if (Number.isNaN(dt.getTime())) return "";
return dt.toLocaleString();
}
@@ -249,11 +289,12 @@
* @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 = '';
input = "";
loading = true;
try {
@@ -271,8 +312,8 @@
} catch (err) {
appendAssistantResponse({
response_id: `error-${Date.now()}`,
text: err.message || 'Assistant request failed',
state: 'failed',
text: err.message || "Assistant request failed",
state: "failed",
created_at: new Date().toISOString(),
actions: [],
});
@@ -289,6 +330,8 @@
* @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.
@@ -308,8 +351,10 @@
* @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'
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `conv-${Date.now()}`;
setAssistantConversationId(newId);
@@ -328,32 +373,37 @@
* @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) {
if (action.type === "open_task" && action.target) {
openDrawerForTask(action.target);
return;
}
if (action.type === 'open_reports') {
goto('/reports');
if (action.type === "open_reports") {
goto("/reports");
return;
}
if (action.type === 'confirm' && message.confirmation_id) {
const response = await confirmAssistantOperation(message.confirmation_id);
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);
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',
text: err.message || "Action failed",
state: "failed",
created_at: new Date().toISOString(),
actions: [],
});
@@ -368,7 +418,8 @@
* @POST: handleSend is invoked when Enter is pressed without shift modifier.
*/
function handleKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
console.log("[AssistantChatPanel][input][handleKeydown][START]");
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
@@ -382,12 +433,17 @@
* @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';
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]
@@ -398,8 +454,9 @@
* @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 || typeof el.scrollTop !== "number") return;
if (el.scrollTop <= 16) {
loadOlderMessages();
}
@@ -412,18 +469,28 @@
</script>
{#if isOpen}
<div class="fixed inset-0 z-[70] bg-slate-900/30" on:click={closeAssistantChat} aria-hidden="true"></div>
<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">
<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>
<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'}
aria-label={$t.assistant?.close || "Close assistant"}
>
<Icon name="close" size={18} />
</button>
@@ -432,7 +499,10 @@
<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>
<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}
@@ -442,14 +512,20 @@
</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')}
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')}
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>
@@ -457,16 +533,27 @@
<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'}"
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>
<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>
<div
class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs text-slate-500"
>
...
</div>
{/if}
{#if conversationsHasNext}
<button
@@ -479,15 +566,29 @@
</div>
</div>
<div class="flex-1 space-y-3 overflow-y-auto p-4" bind:this={historyViewport} on:scroll={handleHistoryScroll}>
<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>
<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>
<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="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>
@@ -497,29 +598,44 @@
{/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={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
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)}">
<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>
<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>
<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'}
{$t.assistant?.open_task_drawer || "Open Task Drawer"}
</button>
</div>
{/if}
@@ -544,12 +660,14 @@
<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">
<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>{$t.assistant?.thinking || "Думаю"}</span>
<span class="thinking-dots" aria-hidden="true">
<span></span><span></span><span></span>
</span>
@@ -564,7 +682,7 @@
<textarea
bind:value={input}
rows="2"
placeholder={$t.assistant?.input_placeholder || 'Type a command...'}
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>
@@ -573,7 +691,7 @@
on:click={handleSend}
disabled={loading || !input.trim()}
>
{loading ? '...' : ($t.assistant?.send || 'Send')}
{loading ? "..." : $t.assistant?.send || "Send"}
</button>
</div>
</div>
@@ -581,6 +699,8 @@
</aside>
{/if}
<!-- [/DEF:AssistantChatPanel:Component] -->
<style>
.thinking-dots {
display: inline-flex;
@@ -618,4 +738,3 @@
}
}
</style>
<!-- [/DEF:AssistantChatPanel:Component] -->