chore: remainder — backend test infra, agent config, docker, i18n, frontend ui

- Backend: alembic env, config manager/models, dependencies, translate plugin
- Backend tests: async_sync_regression, integration tests, git services, test_agent
- Docker: docker-compose.yml updates
- Agent: qa-tester.md update, semantics-testing SKILL.md update
- Frontend: TopNavbar, sidebarNavigation, FeaturesSettings, FeatureGate
- i18n: assistant.json en/ru locale updates
- New: frontend/src/lib/components/agent/ directory
This commit is contained in:
2026-06-10 15:06:36 +03:00
parent 2b303f92b3
commit 143f14d516
34 changed files with 5693 additions and 178 deletions

View File

@@ -0,0 +1,577 @@
<!-- #region AgentChat.Component [C:4] [TYPE Component] [SEMANTICS agent,chat,conversation,ui,gradio] -->
<!-- @ingroup AgentChat -->
<!-- @BRIEF Self-contained agent chat UI — message bubbles, streaming indicator, input bar, sidebar toggle, all semantic tokens. -->
<!-- @RELATION BINDS_TO -> AgentChat.Model -->
<!-- @RELATION DEPENDS_ON -> $lib/ui/Icon -->
<!-- @RELATION DEPENDS_ON -> AgentChat.ConversationList -->
<!-- @UX_STATE idle -> Welcome screen with suggestion chips, input enabled. -->
<!-- @UX_STATE streaming -> Streaming bubble with cursor animation, input locked. -->
<!-- @UX_STATE awaiting_confirmation -> Confirmation card with confirm/deny buttons. -->
<!-- @UX_STATE error -> Error banner with retry button. -->
<!-- @UX_STATE disconnected -> Reconnect button, input locked. -->
<!-- @INVARIANT Messages from current conversation only. -->
<!-- @INVARIANT User messages right-aligned (bg-primary text-white), assistant left-aligned (bg-surface-card). -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import Icon from "$lib/ui/Icon.svelte";
import MarkdownRenderer from "$lib/components/assistant/MarkdownRenderer.svelte";
import ConnectionIndicator from "$lib/components/assistant/ConnectionIndicator.svelte";
import ToolCallCard from "$lib/components/assistant/ToolCallCard.svelte";
import type { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts";
// ── Props ──────────────────────────────────────────────────────
let {
model = $bindable(),
}: {
model: AgentChatModel;
} = $props();
// ── Local state ─────────────────────────────────────────────────
let inputText = $state("");
let messagesContainer: HTMLDivElement | undefined = $state();
let fileInputRef: HTMLInputElement | undefined = $state();
let pendingFile: File | null = $state(null);
let sidebarOpen = $state(false);
const ALLOWED_FILE_TYPES = [".pdf", ".xlsx", ".json", ".csv", ".txt", ".png", ".jpeg", ".jpg"];
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
// ── Derived ─────────────────────────────────────────────────────
let isInputLocked = $derived(
model.isInputLocked || model.streamingState === "awaiting_confirmation"
);
let showWelcome = $derived(
model.messages.length === 0 &&
model.streamingState === "idle" &&
!model.isLoadingHistory
);
// ── Track streaming completion → commit messages ──────────────
let _prevStreamState = $state("");
$effect(() => {
const currentState = model.streamingState;
// When streaming → idle/error: commit partial text as full assistant message
if (_prevStreamState === "streaming" && (currentState === "idle" || currentState === "error")) {
const partialText = model.partialText;
const toolCalls = [...model.activeToolCalls];
if (partialText || toolCalls.length > 0) {
model.messages = [
...model.messages,
{
id: `msg-${Date.now()}`,
conversation_id: model.currentConversationId || "",
role: "assistant",
text: partialText || (toolCalls.length > 0 ? "[Выполнены вызовы инструментов]" : ""),
toolCalls: toolCalls,
created_at: new Date().toISOString(),
},
];
model._persistMessages?.();
}
model.partialText = "";
model.partialTokens = [];
model.activeToolCalls = [];
}
_prevStreamState = currentState;
});
// ── Auto-scroll on new messages or streaming ────────────────────
let _prevMsgLen = 0;
$effect(() => {
const len = model.messages.length;
const isStreaming = model.streamingState === "streaming";
if ((len > _prevMsgLen || isStreaming) && messagesContainer) {
requestAnimationFrame(() => {
messagesContainer!.scrollTo({ top: messagesContainer!.scrollHeight, behavior: "smooth" });
});
}
_prevMsgLen = len;
});
// ── Actions ─────────────────────────────────────────────────────
function handleSend() {
const text = inputText.trim();
const hasFile = pendingFile !== null;
if ((!text && !hasFile) || isInputLocked) return;
// Optimistically add user message (skip if no text and only file)
if (text) {
model.messages = [
...model.messages,
{
id: `user-${Date.now()}`,
conversation_id: model.currentConversationId || "",
role: "user",
text: text,
toolCalls: [],
created_at: new Date().toISOString(),
},
];
}
inputText = "";
const filesToSend: File[] | undefined = pendingFile ? [pendingFile] : undefined;
pendingFile = null;
model.sendMessage(text, filesToSend);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) return;
const file = files[0];
const ext = "." + file.name.split(".").pop()?.toLowerCase();
if (!ALLOWED_FILE_TYPES.includes(ext)) {
target.value = "";
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
target.value = "";
return;
}
pendingFile = file;
target.value = "";
}
function removeFile() {
pendingFile = null;
if (fileInputRef) fileInputRef.value = "";
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / 1024 / 1024).toFixed(1) + " MB";
}
function handleSuggest(text: string) {
inputText = text;
handleSend();
}
// ── Suggestion chips ───────────────────────────────────────────
const suggestions = [
{ label: "Покажи дашборды", text: "Покажи доступные дашборды" },
{ label: "Запустить миграцию", text: "Запусти миграцию" },
{ label: "Статус задач", text: "Проверь статус текущих задач" },
];
// Toggle conversation sidebar
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
</script>
<!-- ── Template ─────────────────────────────────────────────────── -->
<div class="h-full flex flex-col min-h-0 bg-surface-page">
<!-- ── Header ──────────────────────────────────────────────── -->
<div class="flex items-center justify-between gap-2 border-b border-border bg-surface-card px-4 py-3 shrink-0">
<div class="flex items-center gap-3">
<button
class="rounded-lg p-1.5 text-text-muted transition hover:bg-surface-muted hover:text-text md:hidden"
onclick={toggleSidebar}
aria-label="Toggle sidebar"
>
<Icon name="menu" size={20} />
</button>
<div class="flex items-center gap-2">
<ConnectionIndicator
color={model.connectionDotColor as "success" | "warning" | "destructive"}
title={model.connectionState === "connected" ? "Agent connected" : "Agent disconnected"}
/>
<h1 class="text-sm font-semibold text-text">
{model.currentConversationId
? (model.conversations.find(c => c.id === model.currentConversationId)?.title || "Агент-чат")
: "Агент-чат"}
</h1>
</div>
{#if model.connectionState === "disconnected"}
<button
class="rounded-md border border-destructive-ring bg-destructive-light px-2 py-1 text-xs font-medium text-destructive transition hover:bg-destructive hover:text-white"
onclick={() => model.retryConnection()}
>
Переподключиться
</button>
{/if}
</div>
<div class="flex items-center gap-2">
{#if model.streamingState === "streaming"}
<button
class="rounded-lg border border-destructive-ring bg-destructive-light px-3 py-1.5 text-xs font-semibold text-destructive transition hover:bg-destructive hover:text-white"
onclick={() => model.cancelGeneration()}
>
<div class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1"/>
</svg>
{$t.assistant?.stop || "Стоп"}
</div>
</button>
{/if}
<button
class="rounded-lg border border-border-strong bg-surface-card px-3 py-1.5 text-xs font-medium text-text transition hover:bg-surface-muted"
onclick={() => model.createConversation()}
>
<div class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14M5 12h14"/>
</svg>
{$t.assistant?.new || "Новый диалог"}
</div>
</button>
</div>
</div>
<!-- ── Messages Area ───────────────────────────────────────── -->
<div
class="flex-1 overflow-y-auto px-4 py-6 space-y-4 scroll-smooth"
bind:this={messagesContainer}
>
<!-- Welcome screen -->
{#if showWelcome}
<div class="flex flex-col items-center justify-center pt-12 pb-8">
<!-- Avatar/Logo -->
<div class="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-light text-primary shadow-sm">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/>
</svg>
</div>
<h2 class="text-xl font-semibold text-text mb-2">{$t.assistant?.welcome_title || "Чем могу помочь?"}</h2>
<p class="text-sm text-text-muted text-center max-w-md mb-8">
{model.conversations.length === 0
? "Задайте вопрос или выберите одно из предложений ниже."
: "Продолжите диалог или начните новый."}
</p>
<div class="flex flex-wrap gap-2 justify-center">
{#each suggestions as s}
<button
class="rounded-xl border border-primary-ring bg-primary-light px-4 py-2.5 text-sm font-medium text-primary transition hover:bg-primary hover:text-white"
onclick={() => handleSuggest(s.text)}
>
{s.label}
</button>
{/each}
</div>
</div>
{/if}
<!-- Message bubbles -->
{#each model.messages as msg}
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
{#if msg.role === "user"}
<!-- User bubble -->
<div class="max-w-[75%] rounded-2xl bg-primary px-4 py-3 text-white shadow-sm">
<p class="text-sm whitespace-pre-wrap leading-relaxed">{msg.text}</p>
</div>
{:else if msg.role === "tool"}
<!-- Tool message: inline code block -->
<div class="max-w-[80%] rounded-xl border border-border bg-surface-muted px-4 py-3">
<div class="flex items-center gap-2 mb-1">
<svg class="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span class="text-xs font-medium text-text-muted">Система</span>
</div>
<p class="text-sm text-text whitespace-pre-wrap leading-relaxed">{msg.text}</p>
</div>
{:else}
<!-- Assistant bubble -->
<div class="max-w-[80%] rounded-2xl border border-border bg-surface-card px-4 py-3 shadow-sm">
<div class="flex items-center gap-2 mb-2">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-light text-primary">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
</div>
<span class="text-xs font-semibold text-text-muted">
{$t.assistant?.assistant || "Ассистент"}
</span>
{#if msg.state}
<span class="rounded-md border border-warning bg-warning-light px-1.5 py-0.5 text-[10px] font-medium text-warning">
{$t.assistant?.states?.[msg.state] || msg.state}
</span>
{/if}
</div>
<div class="text-sm text-text leading-relaxed">
<MarkdownRenderer source={msg.text} />
</div>
<!-- Confirmation actions -->
{#if msg.state === "needs_confirmation"}
<div class="mt-3 flex flex-wrap gap-2">
{#each msg.actions || [] as action}
<button
class="rounded-lg border px-3 py-1.5 text-xs font-semibold transition
{action.type === 'confirm'
? 'border-success bg-success-light text-success hover:bg-success'
: action.type === 'cancel'
? 'border-destructive-ring bg-destructive-light text-destructive hover:bg-destructive hover:text-white'
: 'border-border-strong bg-surface-card text-text hover:bg-surface-muted'}"
>
{action.label}
</button>
{/each}
</div>
{/if}
<!-- Task ID -->
{#if msg.task_id}
<div class="mt-2 flex items-center gap-1.5 text-xs text-text-muted">
<Icon name="activity" size={14} />
<span>Task: {msg.task_id}</span>
</div>
{/if}
</div>
{/if}
</div>
{/each}
<!-- Loading history indicator -->
{#if model.isLoadingHistory}
<div class="flex justify-center">
<div class="flex items-center gap-2 rounded-xl border border-border bg-surface-muted px-4 py-3 text-sm text-text-muted">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
<span>{$t.assistant?.loading_history || "Загрузка истории..."}</span>
</div>
</div>
{/if}
<!-- Streaming bubble -->
{#if model.streamingState === "streaming"}
<div class="flex justify-start">
<div class="max-w-[80%] rounded-2xl border border-border bg-surface-card px-4 py-3 shadow-sm">
<div class="flex items-center gap-2 mb-2">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-light text-primary">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
</div>
<span class="text-xs font-semibold text-text-muted">
{$t.assistant?.assistant || "Ассистент"}
</span>
<span class="inline-flex items-center gap-1 text-[10px] text-text-subtle">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</span>
</div>
{#if model.activeToolCalls.length > 0}
<div class="space-y-2 mb-3">
{#each model.activeToolCalls as tc}
<ToolCallCard tool={tc.tool} input={tc.input} output={tc.output} error={tc.error} status={tc.status} />
{/each}
</div>
{/if}
{#if model.partialText}
<div class="text-sm text-text leading-relaxed">
<MarkdownRenderer source={model.partialText} />
<span class="inline-block w-1.5 h-4 bg-primary animate-pulse ml-0.5 align-text-bottom rounded-sm"></span>
</div>
{:else}
<div class="flex items-center gap-1 text-sm text-text-muted">
<span>{$t.assistant?.thinking || "Думаю"}</span>
<span class="inline-flex items-center gap-0.5">
<span class="thinking-dot-sm"></span>
<span class="thinking-dot-sm"></span>
<span class="thinking-dot-sm"></span>
</span>
</div>
{/if}
</div>
</div>
{/if}
<!-- Awaiting confirmation -->
{#if model.streamingState === "awaiting_confirmation"}
<div class="flex justify-start">
<div class="max-w-[80%] rounded-2xl border border-warning bg-warning-light px-4 py-4 shadow-sm" role="alertdialog">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-warning shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<span class="text-sm font-semibold text-text">
{$t.assistant?.confirmation_card_title || "Требуется подтверждение"}
</span>
</div>
<p class="text-sm text-text-muted mb-3">{model.error || "Подтвердите выполнение действия"}</p>
<div class="flex gap-2">
<button
class="rounded-lg border border-success bg-white px-4 py-2 text-xs font-semibold text-success transition hover:bg-success-light"
onclick={() => model.resumeConfirm("confirm")}
>
{$t.assistant?.confirm || "Подтвердить"}
</button>
<button
class="rounded-lg border border-destructive-ring bg-white px-4 py-2 text-xs font-semibold text-destructive transition hover:bg-destructive-light"
onclick={() => model.resumeConfirm("deny")}
>
{$t.assistant?.cancel || "Отклонить"}
</button>
</div>
</div>
</div>
{/if}
<!-- Error -->
{#if model.streamingState === "error" && model.error}
<div class="flex justify-start">
<div class="max-w-[80%] rounded-2xl border border-destructive-ring bg-destructive-light px-4 py-3" role="alert">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-destructive shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm font-semibold text-destructive">Ошибка</span>
</div>
<p class="text-sm text-destructive">{model.error}</p>
<button
class="mt-3 rounded-lg border border-destructive-ring bg-white px-3 py-1.5 text-xs font-semibold text-destructive transition hover:bg-destructive-light"
onclick={() => model.sendMessage(inputText)}
>
{$t.assistant?.retry || "Повторить"}
</button>
</div>
</div>
{/if}
<!-- Bottom spacer for scroll -->
<div class="h-2"></div>
</div>
<!-- ── Input Bar ────────────────────────────────────────────── -->
<div class="border-t border-border bg-surface-card px-4 py-3 shrink-0">
{#if pendingFile}
<div class="flex items-center gap-2 mb-2">
<div class="flex items-center gap-1.5 rounded-lg border border-border bg-surface-muted px-3 py-1.5 text-xs text-text-muted">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
</svg>
<span class="truncate max-w-[200px]">{pendingFile.name}</span>
<span class="text-text-subtle">({formatFileSize(pendingFile.size)})</span>
<button class="ml-1 rounded p-0.5 text-text-subtle hover:text-destructive" onclick={removeFile}>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
{/if}
<div class="flex items-end gap-2">
<div class="relative flex-1">
<textarea
bind:value={inputText}
rows="1"
placeholder={$t.assistant?.input_placeholder || "Введите команду..."}
class="min-h-[44px] max-h-[120px] w-full resize-none rounded-xl border border-border-strong bg-surface-page px-4 py-3 pr-12 text-sm text-text outline-none transition placeholder:text-text-subtle focus:border-primary-ring focus:ring-2 focus:ring-primary-light disabled:cursor-not-allowed disabled:opacity-50"
onkeydown={handleKeydown}
disabled={isInputLocked}
oninput={() => {
// Auto-resize textarea
const el = document.activeElement as HTMLTextAreaElement;
if (el) {
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
}}
></textarea>
<button
class="absolute right-2 bottom-2 rounded-lg p-1.5 text-text-subtle transition hover:text-text-muted disabled:opacity-40"
onclick={() => fileInputRef?.click()}
disabled={isInputLocked}
aria-label="Прикрепить файл"
title="Прикрепить файл"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
</svg>
</button>
<input
type="file"
accept=".pdf,.xlsx,.json,.csv,.txt,.png,.jpeg,.jpg"
class="hidden"
bind:this={fileInputRef}
onchange={handleFileSelect}
/>
</div>
{#if model.streamingState === "streaming"}
<button
class="flex h-[44px] w-[44px] items-center justify-center rounded-xl bg-destructive text-white transition hover:bg-destructive-hover"
onclick={() => model.cancelGeneration()}
aria-label="Остановить"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1.5"/>
</svg>
</button>
{:else}
<button
class="flex h-[44px] w-[44px] items-center justify-center rounded-xl bg-primary text-white transition hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-40"
onclick={handleSend}
disabled={!inputText.trim() && !pendingFile || isInputLocked}
aria-label="Отправить"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
{/if}
</div>
</div>
</div>
<style>
.typing-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
animation: typing-bounce 1.2s infinite ease-in-out;
}
.typing-dot:nth-child(2) {
animation-delay: 0.16s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes typing-bounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.thinking-dot-sm {
display: inline-block;
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
opacity: 0.5;
}
</style>
<!-- #endregion AgentChat.Component -->

View File

@@ -146,7 +146,7 @@
}
function handleAssistantClick() {
toggleAssistantChat();
goto("/agent");
}
async function hydrateTaskDrawerPreference() {

View File

@@ -123,9 +123,10 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
path: "/migration",
requiredPermission: "plugin:migration",
requiredAction: "READ",
requiredFeature: "migration",
subItems: [
{ label: nav.migration_overview || "Overview", path: "/migration", requiredPermission: "plugin:migration", requiredAction: "READ" },
{ label: nav.migration_mappings || "Mappings", path: "/migration/mappings", requiredPermission: "plugin:migration", requiredAction: "READ" },
{ label: nav.migration_overview || "Overview", path: "/migration", requiredPermission: "plugin:migration", requiredAction: "READ", requiredFeature: "migration" },
{ label: nav.migration_mappings || "Mappings", path: "/migration/mappings", requiredPermission: "plugin:migration", requiredAction: "READ", requiredFeature: "migration" },
],
},
],
@@ -142,8 +143,9 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
icon: "activity",
tone: "from-purple-100 to-purple-200 text-purple-700 ring-purple-200",
path: "/git",
requiredFeature: "git_integration",
subItems: [
{ label: nav.git_repo_status || "Repository Status", path: "/git" },
{ label: nav.git_repo_status || "Repository Status", path: "/git", requiredFeature: "git_integration" },
],
},
{
@@ -154,10 +156,11 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
path: "/translate",
requiredPermission: "translate.job",
requiredAction: "VIEW",
requiredFeature: "translate",
subItems: [
{ label: nav.translation_jobs, path: "/translate", requiredPermission: "translate.job", requiredAction: "VIEW" },
{ label: nav.translation_dictionaries, path: "/translate/dictionaries", requiredPermission: "translate.dictionary", requiredAction: "VIEW" },
{ label: nav.translation_history, path: "/translate/history", requiredPermission: "translate.job", requiredAction: "VIEW" },
{ label: nav.translation_jobs, path: "/translate", requiredPermission: "translate.job", requiredAction: "VIEW", requiredFeature: "translate" },
{ label: nav.translation_dictionaries, path: "/translate/dictionaries", requiredPermission: "translate.dictionary", requiredAction: "VIEW", requiredFeature: "translate_dictionaries" },
{ label: nav.translation_history, path: "/translate/history", requiredPermission: "translate.job", requiredAction: "VIEW", requiredFeature: "translate" },
],
},
{
@@ -168,9 +171,10 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
path: "/validation-tasks",
requiredPermission: "validation.task",
requiredAction: "VIEW",
requiredFeature: "llm_dashboard_validation",
subItems: [
{ label: nav.validation_tasks, path: "/validation-tasks", requiredPermission: "validation.task", requiredAction: "VIEW" },
{ label: nav.validation_history, path: "/validation-tasks/history", requiredPermission: "validation.run", requiredAction: "VIEW" },
{ label: nav.validation_tasks, path: "/validation-tasks", requiredPermission: "validation.task", requiredAction: "VIEW", requiredFeature: "llm_dashboard_validation" },
{ label: nav.validation_history, path: "/validation-tasks/history", requiredPermission: "validation.run", requiredAction: "VIEW", requiredFeature: "llm_dashboard_validation" },
],
},
{
@@ -200,10 +204,10 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
tone: "from-slate-100 to-slate-200 text-slate-700 ring-slate-200",
path: "/tools/mapper",
subItems: [
{ label: nav.tools_mapper, path: "/tools/mapper" },
{ label: nav.tools_debug, path: "/tools/debug" },
{ label: nav.tools_storage, path: "/tools/storage" },
{ label: nav.tools_backups, path: "/tools/backups" },
{ label: nav.tools_mapper, path: "/tools/mapper", requiredFeature: "dataset_mapper" },
{ label: nav.tools_debug, path: "/tools/debug", requiredFeature: "debug" },
{ label: nav.tools_storage, path: "/tools/storage", requiredFeature: "storage_manager" },
{ label: nav.tools_backups, path: "/tools/backups", requiredFeature: "backup" },
],
},
{
@@ -227,8 +231,9 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
path: "/maintenance",
requiredPermission: "maintenance:write",
requiredAction: "READ",
requiredFeature: "maintenance_banner",
subItems: [
{ label: nav.maintenance || "Maintenance", path: "/maintenance", requiredPermission: "maintenance:write", requiredAction: "READ" },
{ label: nav.maintenance || "Maintenance", path: "/maintenance", requiredPermission: "maintenance:write", requiredAction: "READ", requiredFeature: "maintenance_banner" },
],
},
],
@@ -249,6 +254,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
})
.filter((category) => {
if (!isItemAllowed(user, category)) return false;
if (!isFeatureEnabled(category, features)) return false;
return Array.isArray(category.subItems) && category.subItems.length > 0;
}),
}))

View File

@@ -52,5 +52,38 @@
"needs_confirmation": "Needs confirmation",
"needs_clarification": "Needs clarification",
"denied": "Denied"
}
},
"stop": "Stop",
"tool_executing": "Executing...",
"tool_completed": "Completed",
"tool_failed": "Failed",
"confirm_required": "Confirmation required",
"confirm_expired": "Confirmation expired",
"confirm_retry": "Retry request",
"confirm": "Confirm",
"deny": "Deny",
"file_upload": "Attach file",
"file_parse_error": "Could not parse file",
"file_unsupported": "Unsupported format",
"file_too_large": "File too large (max 10MB)",
"connection_lost": "Connection lost",
"reconnecting": "Reconnecting...",
"manual_reconnect": "Manual reconnect",
"archive_dialog": "Archive this conversation?",
"delete_confirm": "Delete this conversation?",
"load_more": "Load more",
"no_conversations": "No conversations",
"today": "Today",
"yesterday": "Yesterday",
"this_week": "This week",
"search_placeholder": "Search conversations...",
"new_conversation": "New Conversation",
"delete": "Delete",
"retry": "Retry",
"details": "Details",
"hide": "Hide",
"welcome_title": "Ready to work",
"welcome_start_action": "Start",
"welcome_custom_action": "Custom question",
"expand_to_agent": "Expand"
}

View File

@@ -52,5 +52,38 @@
"needs_confirmation": "Требует подтверждения",
"needs_clarification": "Нужно уточнение",
"denied": "Доступ запрещен"
}
},
"stop": "Стоп",
"tool_executing": "Выполняется...",
"tool_completed": "Выполнено",
"tool_failed": "Ошибка",
"confirm_required": "Требуется подтверждение",
"confirm_expired": "Время подтверждения истекло",
"confirm_retry": "Повторить запрос",
"confirm": "Подтвердить",
"deny": "Отклонить",
"file_upload": "Прикрепить файл",
"file_parse_error": "Не удалось обработать файл",
"file_unsupported": "Неподдерживаемый формат",
"file_too_large": "Файл слишком большой (макс. 10MB)",
"connection_lost": "Соединение потеряно",
"reconnecting": "Переподключение...",
"manual_reconnect": "Подключиться вручную",
"archive_dialog": "Архивировать этот диалог?",
"delete_confirm": "Удалить этот диалог?",
"load_more": "Загрузить еще",
"no_conversations": "Нет диалогов",
"today": "Сегодня",
"yesterday": "Вчера",
"this_week": "На этой неделе",
"search_placeholder": "Поиск диалогов...",
"new_conversation": "Новый диалог",
"delete": "Удалить",
"retry": "Повторить",
"details": "Детали",
"hide": "Скрыть",
"welcome_title": "Готов к работе",
"welcome_start_action": "Начать",
"welcome_custom_action": "Свой вопрос",
"expand_to_agent": "Развернуть"
}

View File

@@ -0,0 +1,52 @@
<!-- #region FeatureGate [C:2] [TYPE Component] [SEMANTICS feature, gate, conditional rendering] -->
<!-- @ingroup UI -->
<!-- @BRIEF Conditionally renders children based on a feature flag.
If disabled and redirect is provided, navigates to that path.
Otherwise shows an empty state. -->
<!-- @UX_STATE Enabled -> Renders children. -->
<!-- @UX_STATE Disabled -> Shows empty state or redirects. -->
<!-- @PRE: features object is available (from settings or fetch). -->
<!-- @POST: Children are rendered only if the feature is enabled. -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
let {
feature = $bindable(""),
features = $bindable({} as Record<string, boolean | undefined>),
redirect = $bindable(""),
children,
}: {
feature: string;
features: Record<string, boolean | undefined>;
redirect?: string;
children?: import("svelte").Snippet;
} = $props();
let isEnabled = $derived(features[feature] !== false);
// If redirect is set and feature is disabled, we navigate
// (caller should handle this via goto or a href)
</script>
{#if isEnabled}
{@render children?.()}
{:else if redirect}
<div class="text-center py-12">
<p class="text-text-muted text-lg">
{$t.common?.feature_disabled || `Feature "${feature}" is disabled.`}
</p>
<a
href={redirect}
class="text-primary hover:text-primary-hover underline mt-2 inline-block"
>
{$t.common?.go_back || "Go back"}
</a>
</div>
{:else}
<div class="text-center py-12">
<p class="text-text-muted text-lg">
{$t.common?.feature_disabled || `Feature "${feature}" is disabled.`}
</p>
</div>
{/if}
<!-- #endregion FeatureGate -->

View File

@@ -1,14 +1,99 @@
<!-- #region FeaturesSettings [C:2] [TYPE Component] [SEMANTICS settings, features, toggle, enable] -->
<!-- #region FeaturesSettings [C:3] [TYPE Component] [SEMANTICS settings, features, toggle, enable] -->
<!-- @ingroup Routes -->
<!-- @BRIEF Feature flags configuration tab: enable/disable top-level application features. -->
<!-- @BRIEF Dynamic feature flags configuration tab: enable/disable all application features. -->
<!-- @LAYER UI -->
<!-- @PRE: settings object with features config is provided. -->
<!-- @POST: User can toggle feature flags. -->
<!-- @POST: User can toggle all feature flags dynamically rendered from FeaturesConfig. -->
<!-- @RATIONALE Dynamic rendering: features are derived from backend FeaturesConfig model.
Each feature gets a toggle with i18n label if available. -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import HelpTooltip from "$lib/ui/HelpTooltip.svelte";
let { settings = $bindable(), onSave } = $props();
// Feature descriptors: ordered list for UI display.
// Each entry maps to settings.features.<id> with optional i18n keys.
// i18n keys follow pattern: feature_<id>, feature_<id>_hint, help_feature_<id>
const FEATURES = [
// Core features
{ id: "dataset_review", category: "core" },
{ id: "health_monitor", category: "core" },
// Translation subsystem
{ id: "translate", category: "translate" },
{ id: "translate_scheduling", category: "translate" },
{ id: "translate_dictionaries", category: "translate" },
// Dashboard migration & version control
{ id: "migration", category: "migration" },
{ id: "git_integration", category: "migration" },
// System tools
{ id: "backup", category: "tools" },
{ id: "dataset_mapper", category: "tools" },
{ id: "debug", category: "tools" },
{ id: "storage_manager", category: "tools" },
{ id: "search_datasets", category: "tools" },
{ id: "maintenance_banner", category: "tools" },
// LLM analysis tools
{ id: "llm_dashboard_validation", category: "llm" },
{ id: "llm_documentation", category: "llm" },
];
// Category labels
const CATEGORIES: Record<string, string> = {
core: "Core",
translate: "Translation",
migration: "Migration & Version Control",
tools: "System Tools",
llm: "LLM Analysis",
};
function featureLabel(id: string): string {
// Try i18n first
const key = `feature_${id}` as keyof typeof $t.settings;
const i18nVal = $t.settings?.[key];
if (i18nVal && typeof i18nVal === "string") return i18nVal;
// Fallback: human-readable from snake_case
return id
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function featureHint(id: string): string {
const key = `feature_${id}_hint` as keyof typeof $t.settings;
const i18nVal = $t.settings?.[key];
if (i18nVal && typeof i18nVal === "string") return i18nVal;
return "";
}
function featureHelp(id: string): string {
const key = `help_feature_${id}` as keyof typeof $t.settings;
const i18nVal = $t.settings?.[key];
if (i18nVal && typeof i18nVal === "string") return i18nVal;
return "";
}
function toggleFeature(id: string): void {
if (settings?.features && id in settings.features) {
settings.features[id] = !settings.features[id];
// Force reactivity
settings.features = { ...settings.features };
}
}
// Group features by category
const groupedFeatures = $derived.by(() => {
const grouped: Record<string, typeof FEATURES> = {};
for (const f of FEATURES) {
if (!grouped[f.category]) grouped[f.category] = [];
grouped[f.category].push(f);
}
return grouped;
});
</script>
<div class="text-lg font-medium mb-4">
@@ -16,58 +101,55 @@
{$t.settings?.features || "Features"}
</h2>
<p class="text-text-muted mb-6">
{$t.settings?.features_description || "Enable or disable top-level application features."}
{$t.settings?.features_description || "Enable or disable application features. Disabled features are hidden from the UI and blocked at the API level."}
</p>
<div class="bg-surface-muted p-6 rounded-lg border border-border">
<div class="space-y-4">
<!-- dataset_review toggle -->
<div class="flex items-center justify-between">
<div>
<span class="flex items-center gap-1.5">
<label for="feature-dataset-review" class="text-sm font-medium text-text">
{$t.settings?.feature_dataset_review || "Dataset Review"}
{#each Object.entries(groupedFeatures) as [category, features]}
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3 text-text">
{CATEGORIES[category] || category}
</h3>
<div class="bg-surface-muted rounded-lg border border-border">
{#each features as feature, i}
<div class="flex items-center justify-between p-4" class:border-t={i > 0} class:border-border={i > 0}>
<div class="flex-1 mr-4">
<span class="flex items-center gap-1.5">
<label for="feature-{feature.id}" class="text-sm font-medium text-text">
{featureLabel(feature.id)}
</label>
{#if featureHelp(feature.id)}
<HelpTooltip text={featureHelp(feature.id)} />
{/if}
</span>
{#if featureHint(feature.id)}
<p class="text-xs text-text-muted mt-1">
{featureHint(feature.id)}
</p>
{/if}
</div>
<label class="relative inline-flex items-center cursor-pointer flex-shrink-0">
<input
id="feature-{feature.id}"
type="checkbox"
class="sr-only peer"
checked={settings?.features?.[feature.id] ?? true}
onchange={() => toggleFeature(feature.id)}
>
<div class="w-11 h-6 bg-surface-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-surface-card after:border-border-strong after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
<HelpTooltip text={$t.settings?.help_feature_dataset_review || ""} />
</span>
<p class="text-xs text-text-muted mt-1">
{$t.settings?.feature_dataset_review_hint || "Automatic dataset review, clarification workflow, and SQL execution."}
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input id="feature-dataset-review" type="checkbox" class="sr-only peer" bind:checked={settings.features.dataset_review}>
<div class="w-11 h-6 bg-surface-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-surface-card after:border-border-strong after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
<!-- health_monitor toggle -->
<div class="flex items-center justify-between border-t border-border pt-4">
<div>
<span class="flex items-center gap-1.5">
<label for="feature-health-monitor" class="text-sm font-medium text-text">
{$t.settings?.feature_health_monitor || "Health Monitor"}
</label>
<HelpTooltip text={$t.settings?.help_feature_health_monitor || ""} />
</span>
<p class="text-xs text-text-muted mt-1">
{$t.settings?.feature_health_monitor_hint || "Dashboard health monitoring and validation report aggregation."}
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input id="feature-health-monitor" type="checkbox" class="sr-only peer" bind:checked={settings.features.health_monitor}>
<div class="w-11 h-6 bg-surface-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-surface-card after:border-border-strong after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
{/each}
</div>
</div>
{/each}
<div class="mt-6 flex justify-end">
<button
onclick={onSave}
class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-hover"
>
{$t.settings?.save_features || $t.settings?.save_logging || "Save"}
</button>
</div>
<div class="mt-6 flex justify-end">
<button
onclick={onSave}
class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-hover"
>
{$t.settings?.save_features || $t.settings?.save || "Save"}
</button>
</div>
</div>
<!-- #endregion FeaturesSettings -->
<!-- #endregion FeaturesSettings -->