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:
577
frontend/src/lib/components/agent/AgentChat.svelte
Normal file
577
frontend/src/lib/components/agent/AgentChat.svelte
Normal 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 -->
|
||||
@@ -146,7 +146,7 @@
|
||||
}
|
||||
|
||||
function handleAssistantClick() {
|
||||
toggleAssistantChat();
|
||||
goto("/agent");
|
||||
}
|
||||
|
||||
async function hydrateTaskDrawerPreference() {
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Развернуть"
|
||||
}
|
||||
|
||||
52
frontend/src/lib/ui/FeatureGate.svelte
Normal file
52
frontend/src/lib/ui/FeatureGate.svelte
Normal 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 -->
|
||||
@@ -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 -->
|
||||
Reference in New Issue
Block a user