840 lines
29 KiB
Svelte
840 lines
29 KiB
Svelte
<!-- [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}
|
|
* @TEST_DATA: assistant_llm_ready -> {"llmStatus":{"configured":true,"reason":"ok"},"messages":[{"role":"assistant","text":"Ready","state":"success"}]}
|
|
* @TEST_DATA: assistant_llm_not_configured -> {"llmStatus":{"configured":false,"reason":"invalid_api_key"}}
|
|
*
|
|
* @TEST_CONTRACT Component_AssistantChatPanel ->
|
|
* {
|
|
* required_props: {},
|
|
* optional_props: {},
|
|
* invariants: [
|
|
* "Loads history and LLM status on mount/open",
|
|
* "Appends messages and sends to API correctly",
|
|
* "Handles action buttons (confirm, open task) properly"
|
|
* ]
|
|
* }
|
|
* @TEST_FIXTURE chat_open -> {"isOpen": true, "messages": [{"role": "assistant", "text": "Hello"}]}
|
|
* @TEST_EDGE server_error -> Appends error message with "failed" state
|
|
* @TEST_EDGE llm_not_ready -> Renders LLM config warning banner
|
|
* @TEST_INVARIANT action_handling -> verifies: [chat_open]
|
|
*/
|
|
|
|
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";
|
|
import { api } from "$lib/api.js";
|
|
import { gitService } from "../../../services/gitService.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;
|
|
let llmReady = true;
|
|
let llmStatusReason = "";
|
|
|
|
$: 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();
|
|
loadLlmStatus();
|
|
}
|
|
|
|
$: 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 $t.assistant?.conversation;
|
|
return `${$t.assistant?.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 || $t.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 === "open_route" && action.target) {
|
|
goto(action.target);
|
|
return;
|
|
}
|
|
|
|
if (action.type === "open_diff" && action.target) {
|
|
const dashboardId = Number(action.target);
|
|
if (!Number.isFinite(dashboardId) || dashboardId <= 0) {
|
|
throw new Error("Invalid dashboard id for diff");
|
|
}
|
|
const diffPayload = await gitService.getDiff(dashboardId);
|
|
const diffText =
|
|
typeof diffPayload === "string"
|
|
? diffPayload
|
|
: diffPayload?.diff || JSON.stringify(diffPayload, null, 2);
|
|
appendAssistantResponse({
|
|
response_id: `diff-${Date.now()}`,
|
|
text: diffText
|
|
? `Diff для дашборда ${dashboardId}:\n\n${diffText}`
|
|
: `Diff для дашборда ${dashboardId} пуст.`,
|
|
state: "success",
|
|
created_at: new Date().toISOString(),
|
|
actions: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (action.type === "confirm" && message.confirmation_id) {
|
|
// Hide buttons immediately to prevent repeated clicks
|
|
messages = messages.map((m) =>
|
|
m.message_id === message.message_id ? { ...m, actions: [] } : m,
|
|
);
|
|
const response = await confirmAssistantOperation(
|
|
message.confirmation_id,
|
|
);
|
|
appendAssistantResponse(response);
|
|
return;
|
|
}
|
|
|
|
if (action.type === "cancel" && message.confirmation_id) {
|
|
// Hide buttons immediately to prevent repeated clicks
|
|
messages = messages.map((m) =>
|
|
m.message_id === message.message_id ? { ...m, actions: [] } : m,
|
|
);
|
|
const response = await cancelAssistantOperation(
|
|
message.confirmation_id,
|
|
);
|
|
appendAssistantResponse(response);
|
|
}
|
|
} catch (err) {
|
|
appendAssistantResponse({
|
|
response_id: `action-error-${Date.now()}`,
|
|
text: err.message || $t.assistant?.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;
|
|
});
|
|
|
|
async function loadLlmStatus() {
|
|
try {
|
|
const status = await api.getLlmStatus();
|
|
llmReady = Boolean(status?.configured);
|
|
llmStatusReason = status?.reason || "";
|
|
} catch (_err) {
|
|
llmReady = false;
|
|
llmStatusReason = "status_unavailable";
|
|
}
|
|
}
|
|
</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}
|
|
</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}
|
|
>
|
|
<Icon name="close" size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex h-[calc(100%-56px)] flex-col">
|
|
{#if !llmReady}
|
|
<div
|
|
class="mx-3 mt-3 rounded-lg border border-rose-300 bg-rose-50 px-3 py-2 text-xs text-rose-800"
|
|
>
|
|
<div class="font-semibold">
|
|
{$t.dashboard?.llm_not_configured || "LLM is not configured"}
|
|
</div>
|
|
<div class="mt-1 text-rose-700">
|
|
{#if llmStatusReason === "no_active_provider"}
|
|
{$t.dashboard?.llm_configure_provider ||
|
|
"No active LLM provider. Configure it in Admin -> LLM Settings."}
|
|
{:else if llmStatusReason === "invalid_api_key"}
|
|
{$t.dashboard?.llm_configure_key ||
|
|
"Invalid LLM API key. Update and save a real key in Admin -> LLM Settings."}
|
|
{:else}
|
|
{$t.dashboard?.llm_status_unavailable ||
|
|
"LLM status is unavailable. Check settings and backend logs."}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<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"
|
|
>{$t.assistant?.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}
|
|
>
|
|
{$t.assistant?.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")}
|
|
>
|
|
{$t.assistant?.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")}
|
|
>
|
|
{$t.assistant?.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)}
|
|
>
|
|
{$t.assistant?.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"
|
|
>
|
|
{$t.assistant?.loading_older}
|
|
</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}
|
|
</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}
|
|
<div class="mt-2 space-y-1 text-xs">
|
|
<div>• {$t.assistant?.sample_command_branch}</div>
|
|
<div>• {$t.assistant?.sample_command_migration}</div>
|
|
<div>• {$t.assistant?.sample_command_status}</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"
|
|
? $t.assistant?.you
|
|
: $t.assistant?.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"
|
|
>{$t.assistant?.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}
|
|
</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"
|
|
>
|
|
{$t.assistant?.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}
|
|
class="min-h-[52px] w-full resize-y rounded-lg border px-3 py-2 text-sm outline-none transition {llmReady
|
|
? 'border-slate-300 focus:border-sky-400 focus:ring-2 focus:ring-sky-100'
|
|
: 'border-rose-300 bg-rose-50 focus:border-rose-400 focus:ring-2 focus:ring-rose-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}
|
|
</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>
|