feat(agent-chat): 035-agent-chat-context — контекст, guardrails, tools, database discovery
== User stories == US1: Контекст с дашборда/датасета → /agent с URL params US2: Guardrails card — env badge, 7 risk tones, countdown, permission_denied US3: Tools optimization — retry, timeout, summarise, RBAC + context affinity == Backend == - _context.py (NEW): UIContext validation (7 checks) - _tool_filter.py (NEW): RBAC + context affinity pipeline - _confirmation.py: build_confirmation_contract_v2, permission_denied_payload - tools.py: superset_list_databases, retry/summarise/timeout wrappers - app.py: _inject_uicontext, _inject_env_id_into_tools, database prefetch в runtime context - _persistence.py: prefetch_databases() - agent_superset_explore.py: GET /databases endpoint - _llm_async_http.py, _persistence.py: fix double /v1 в LLM URL (LM Studio Unexpected endpoint) == Frontend == - AgentChatModel.svelte.ts: 5 atoms, 3 actions, countdown, context - AgentChat.svelte: production banner, process steps, debug panel - ConfirmationCard.svelte: 7 risk tones, permission_denied, countdown - ToolCallCard.svelte: retrying/timeout/cancelled states - StreamProcessor.svelte.ts: tool_retry, timeout, permission_denied - TopNavbar: sparkles icon + Ассистент - sidebarNavigation: AI section - DashboardHeader, datasets/+page: contextual AI buttons - Icon: sparkles, brain, cpu icons - tailwind: assistant category colors - i18n: en/ru nav keys == Tests == - 159 backend agent tests (+16 US3: retry, timeout, summarise, contracts) - 2544 frontend tests (+11 model + component tests) - 15 JSON fixtures (10 API + 5 model) == Specs == - specs/035-agent-chat-context/: spec, UX, plan, tasks, research, data-model, contracts, quickstart, traceability, fixtures, checklists Closes #035
This commit is contained in:
@@ -361,7 +361,13 @@
|
||||
</script>
|
||||
|
||||
<!-- ── Template ─────────────────────────────────────────────────── -->
|
||||
<div class="h-full flex flex-col min-h-0 bg-surface-page">
|
||||
<div class="h-full flex flex-col min-h-0 {model.showProdWarning ? 'bg-warning-light' : 'bg-surface-page'}">
|
||||
{#if model.showProdWarning}
|
||||
<div class="bg-destructive text-white text-xs font-bold text-center py-0.5 shrink-0" role="status" aria-live="polite">
|
||||
<Icon name="warning" size={12} class="inline mr-1" />
|
||||
PRODUCTION
|
||||
</div>
|
||||
{/if}
|
||||
<!-- ── Header ──────────────────────────────────────────────── -->
|
||||
<div class="border-b border-border bg-surface-card px-4 py-3 shrink-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
@@ -616,6 +622,16 @@
|
||||
<div class="truncate font-mono text-text" title={model.error || ""}>{model.error?.slice(0, 80) || "none"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Контекст страницы ── -->
|
||||
<div class="mb-3">
|
||||
<h3 class="text-xs font-semibold text-text-muted uppercase tracking-wide mb-1">Контекст страницы</h3>
|
||||
{#if model.uiContext}
|
||||
<pre class="bg-surface-muted rounded-md p-2 font-mono text-xs text-text max-h-32 overflow-auto">{JSON.stringify(model.uiContext, null, 2)}</pre>
|
||||
{:else}
|
||||
<p class="text-xs text-text-subtle">Контекст не передан</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -776,7 +776,7 @@
|
||||
title={agentModel.connectionState === "connected" ? "Agent connected" : "Agent disconnected"}
|
||||
/>
|
||||
{/if}
|
||||
<Icon name="clipboard" size={18} />
|
||||
<Icon name="aiAssistant" size={18} />
|
||||
<h2 class="text-sm font-semibold">{$t.assistant?.title}</h2>
|
||||
</div>
|
||||
{#if variant === "drawer"}
|
||||
|
||||
@@ -34,10 +34,12 @@
|
||||
);
|
||||
let titleText = $derived(
|
||||
model.confirmationMessageOverride ||
|
||||
(model.pendingRiskLevel === "permission_denied" ? "Недостаточно прав" : "") ||
|
||||
$t.assistant?.[model.confirmationTitleKey] ||
|
||||
$t.assistant?.confirmation_card_title ||
|
||||
"Требуется подтверждение",
|
||||
);
|
||||
let isPermissionDenied = $derived(model.pendingRiskLevel === "permission_denied");
|
||||
let toneClasses = $derived(
|
||||
model.confirmationTone === "success"
|
||||
? {
|
||||
@@ -46,24 +48,52 @@
|
||||
details: "border-success/30 bg-white/70",
|
||||
badge: "bg-success-light text-success",
|
||||
}
|
||||
: model.confirmationTone === "warning"
|
||||
: model.confirmationTone === "warning" && model.pendingRiskLevel === "dangerous"
|
||||
? {
|
||||
card: "border-warning bg-warning-light",
|
||||
icon: "bg-warning/20 text-warning",
|
||||
details: "border-warning/30 bg-white/60",
|
||||
badge: "bg-warning-light text-warning",
|
||||
card: "border-destructive-ring bg-destructive-light",
|
||||
icon: "bg-destructive/20 text-destructive",
|
||||
details: "border-destructive/30 bg-white/60",
|
||||
badge: "bg-destructive-light text-destructive",
|
||||
}
|
||||
: {
|
||||
card: "border-border-strong bg-surface-card",
|
||||
icon: "bg-surface-muted text-text-muted",
|
||||
details: "border-border bg-surface-page",
|
||||
badge: "bg-surface-muted text-text-muted",
|
||||
},
|
||||
: model.confirmationTone === "warning" && model.pendingRiskLevel === "permission_denied"
|
||||
? {
|
||||
card: "border-info bg-info-light",
|
||||
icon: "bg-info/20 text-info",
|
||||
details: "border-info/30 bg-white/60",
|
||||
badge: "bg-info-light text-info",
|
||||
}
|
||||
: model.confirmationTone === "warning"
|
||||
? {
|
||||
card: "border-warning bg-warning-light",
|
||||
icon: "bg-warning/20 text-warning",
|
||||
details: "border-warning/30 bg-white/60",
|
||||
badge: "bg-warning-light text-warning",
|
||||
}
|
||||
: model.confirmationTone === "destructive"
|
||||
? {
|
||||
card: "border-destructive-ring bg-destructive-light",
|
||||
icon: "bg-destructive/20 text-destructive",
|
||||
details: "border-destructive/30 bg-white/60",
|
||||
badge: "bg-destructive-light text-destructive",
|
||||
}
|
||||
: model.pendingRiskLevel === "dangerous"
|
||||
? {
|
||||
card: "border-destructive-ring bg-destructive-light",
|
||||
icon: "bg-destructive/20 text-destructive",
|
||||
details: "border-destructive/30 bg-white/60",
|
||||
badge: "bg-destructive-light text-destructive",
|
||||
}
|
||||
: {
|
||||
card: "border-border-strong bg-surface-card",
|
||||
icon: "bg-surface-muted text-text-muted",
|
||||
details: "border-border bg-surface-page",
|
||||
badge: "bg-surface-muted text-text-muted",
|
||||
},
|
||||
);
|
||||
|
||||
// ── Autofocus on card mount ────────────────────────────────
|
||||
$effect(() => {
|
||||
if (model.streamingState === "awaiting_confirmation" && cardEl) {
|
||||
if ((model.streamingState === "awaiting_confirmation" || model.pendingRiskLevel === "permission_denied") && cardEl) {
|
||||
const firstBtn = cardEl.querySelector<HTMLButtonElement>("button");
|
||||
firstBtn?.focus();
|
||||
}
|
||||
@@ -82,7 +112,7 @@
|
||||
}
|
||||
|
||||
function handleWindowKeydown(e: KeyboardEvent) {
|
||||
if (model.streamingState !== "awaiting_confirmation" || loading !== "idle") return;
|
||||
if ((model.streamingState !== "awaiting_confirmation" && model.pendingRiskLevel !== "permission_denied") || loading !== "idle") return;
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
handleDeny();
|
||||
@@ -90,6 +120,7 @@
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────
|
||||
async function handleConfirm() {
|
||||
if (isPermissionDenied) return;
|
||||
if (loading !== "idle") return;
|
||||
loading = "confirming";
|
||||
try {
|
||||
@@ -101,6 +132,10 @@
|
||||
|
||||
async function handleDeny() {
|
||||
if (loading !== "idle") return;
|
||||
if (isPermissionDenied) {
|
||||
model.dismissPermissionDenied();
|
||||
return;
|
||||
}
|
||||
loading = "denying";
|
||||
try {
|
||||
await model.resumeConfirm("deny");
|
||||
@@ -112,7 +147,7 @@
|
||||
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
{#if model.streamingState === "awaiting_confirmation"}
|
||||
{#if model.streamingState === "awaiting_confirmation" || model.pendingRiskLevel === "permission_denied"}
|
||||
<div class="flex justify-start">
|
||||
<div
|
||||
bind:this={cardEl}
|
||||
@@ -132,6 +167,15 @@
|
||||
<h3 id="confirm-title" class="text-sm font-semibold text-text">
|
||||
{titleText}
|
||||
</h3>
|
||||
{#if model.pendingRiskLevel && model.pendingRiskLevel !== "safe"}
|
||||
<span class="rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold
|
||||
{model.pendingRiskLevel === 'dangerous' ? 'bg-destructive-light text-destructive' : ''}
|
||||
{model.pendingRiskLevel === 'guarded' ? 'bg-warning-light text-warning' : ''}
|
||||
{model.pendingRiskLevel === 'permission_denied' ? 'bg-info-light text-info' : ''}
|
||||
">
|
||||
{model.pendingRiskLevel === 'dangerous' ? '💀 dangerous' : model.pendingRiskLevel === 'guarded' ? '⚠️ guarded' : model.pendingRiskLevel === 'permission_denied' ? '🔒 permission_denied' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
<p id="confirm-desc" class="mt-0.5 text-xs text-text-subtle">
|
||||
{fallbackDescription}
|
||||
</p>
|
||||
@@ -168,17 +212,21 @@
|
||||
|
||||
<!-- ── Actions ───────────────────────────────────────── -->
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={loading === "confirming"}
|
||||
disabled={loading !== "idle"}
|
||||
onclick={handleConfirm}
|
||||
>
|
||||
{loading === "confirming"
|
||||
? ($t.assistant?.confirming || "Подтверждение…")
|
||||
: ($t.assistant?.confirm || "Подтвердить")}
|
||||
</Button>
|
||||
{#if !isPermissionDenied}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={loading === "confirming"}
|
||||
disabled={loading !== "idle" || model.dangerousCountdownActive}
|
||||
onclick={handleConfirm}
|
||||
>
|
||||
{model.dangerousCountdownActive && model.dangerousCountdown > 0
|
||||
? `Подтвердить (${model.dangerousCountdown})`
|
||||
: loading === "confirming"
|
||||
? ($t.assistant?.confirming || "Подтверждение…")
|
||||
: ($t.assistant?.confirm || "Подтвердить")}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -187,7 +235,9 @@
|
||||
onclick={handleDeny}
|
||||
class="text-destructive hover:bg-destructive-light hover:text-destructive"
|
||||
>
|
||||
{loading === "denying"
|
||||
{isPermissionDenied
|
||||
? "Закрыть"
|
||||
: loading === "denying"
|
||||
? ($t.assistant?.cancelling || "Отмена…")
|
||||
: ($t.assistant?.cancel || "Отклонить")}
|
||||
</Button>
|
||||
@@ -195,7 +245,7 @@
|
||||
|
||||
<!-- ── Keyboard hint ────────────────────────────────── -->
|
||||
<p class="mt-2.5 text-[11px] text-text-subtle text-center">
|
||||
↵ Подтвердить · ⎋ Отклонить
|
||||
{isPermissionDenied ? "Esc Закрыть" : "Enter Подтвердить · Esc Отклонить"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,12 +15,22 @@
|
||||
output,
|
||||
error,
|
||||
status,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
timeoutSeconds,
|
||||
isWriteTool,
|
||||
onRetry,
|
||||
}: {
|
||||
tool: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
status: "executing" | "completed" | "failed";
|
||||
status: "executing" | "completed" | "failed" | "retrying" | "timeout" | "cancelled";
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
timeoutSeconds?: number;
|
||||
isWriteTool?: boolean;
|
||||
onRetry?: () => void;
|
||||
} = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
@@ -35,6 +45,32 @@
|
||||
</svg>
|
||||
{:else if status === "completed"}
|
||||
<Icon name="check" size={14} class="text-success" strokeWidth={2} />
|
||||
{:else if status === "retrying"}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block animate-spin">⟳</span>
|
||||
<span class="text-xs text-text-muted">
|
||||
Повторная попытка... ({retryCount}/{maxRetries})
|
||||
</span>
|
||||
</div>
|
||||
{:else if status === "timeout"}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>⏱️</span>
|
||||
<span class="text-xs text-warning">Превышено время ожидания ({timeoutSeconds}с)</span>
|
||||
</div>
|
||||
{#if !isWriteTool}
|
||||
<button class="text-xs text-primary hover:underline" onclick={() => onRetry?.()}>
|
||||
🔄 Повторить
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-text-subtle">Проверьте статус операции в Superset</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if status === "cancelled"}
|
||||
<div class="flex items-center gap-2">
|
||||
<span>🚫</span>
|
||||
<span class="text-xs text-text-muted">Отменено пользователем</span>
|
||||
</div>
|
||||
{:else}
|
||||
<Icon name="close" size={14} class="text-destructive" strokeWidth={2} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// #region Test.AgentChat.ToolCallCard.Ux [C:3] [TYPE Module] [SEMANTICS test,agent,tools,ux]
|
||||
// @BRIEF L2 UX tests for ToolCallCard retrying/timeout/cancelled states — verifies visual indicators, action buttons, and callback wiring.
|
||||
// @RELATION BINDS_TO -> [AgentChat.ToolCallCard]
|
||||
// @TEST_EDGE: retrying_rendered -> status=retrying shows ⟳ spinner with retry count text
|
||||
// @TEST_EDGE: read_timeout_rendered -> status=timeout (read tool) shows timeout message and retry button
|
||||
// @TEST_EDGE: write_timeout_rendered -> status=timeout (write tool) shows Superset status message, NO retry button
|
||||
// @TEST_EDGE: cancelled_rendered -> status=cancelled shows 🚫 icon and user-cancelled message
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||
import ToolCallCard from "../ToolCallCard.svelte";
|
||||
|
||||
// #region Test.AgentChat.ToolCallCard.Ux.Retrying [C:2] [TYPE Test]
|
||||
// @UX_STATE retrying — ⟳ spinner with retry count text.
|
||||
describe("ToolCallCard — retrying state", () => {
|
||||
it("shows spinner icon and retry count", () => {
|
||||
const { container } = render(ToolCallCard, {
|
||||
props: {
|
||||
tool: "run_migration",
|
||||
status: "retrying",
|
||||
retryCount: 1,
|
||||
maxRetries: 2,
|
||||
},
|
||||
});
|
||||
// ⟳ spinner with animate-spin class
|
||||
const spinner = container.querySelector("span.animate-spin");
|
||||
expect(spinner).toBeTruthy();
|
||||
expect(spinner?.textContent?.trim()).toBe("⟳");
|
||||
// Retry count text
|
||||
expect(container.textContent).toContain("Повторная попытка...");
|
||||
expect(container.textContent).toMatch(/\(1\/2\)/);
|
||||
});
|
||||
|
||||
it("shows tool name during retry", () => {
|
||||
render(ToolCallCard, {
|
||||
props: {
|
||||
tool: "run_migration",
|
||||
status: "retrying",
|
||||
retryCount: 1,
|
||||
maxRetries: 2,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("run_migration")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
// #endregion Test.AgentChat.ToolCallCard.Ux.Retrying
|
||||
|
||||
// #region Test.AgentChat.ToolCallCard.Ux.ReadTimeout [C:2] [TYPE Test]
|
||||
// @UX_STATE timeout (read tool) — ⏱️ icon, timeout message, retry button with onRetry callback.
|
||||
describe("ToolCallCard — read timeout state", () => {
|
||||
it("shows timeout icon and message", () => {
|
||||
const { container } = render(ToolCallCard, {
|
||||
props: {
|
||||
tool: "search_dashboards",
|
||||
status: "timeout",
|
||||
timeoutSeconds: 30,
|
||||
isWriteTool: false,
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toContain("⏱️");
|
||||
expect(container.textContent).toContain("Превышено время ожидания");
|
||||
expect(container.textContent).toContain("30с");
|
||||
});
|
||||
|
||||
it("shows retry button", () => {
|
||||
render(ToolCallCard, {
|
||||
props: {
|
||||
tool: "search_dashboards",
|
||||
status: "timeout",
|
||||
timeoutSeconds: 30,
|
||||
isWriteTool: false,
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /Повторить/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fires onRetry callback when retry button is clicked", async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(ToolCallCard, {
|
||||
props: {
|
||||
tool: "search_dashboards",
|
||||
status: "timeout",
|
||||
timeoutSeconds: 30,
|
||||
isWriteTool: false,
|
||||
onRetry,
|
||||
},
|
||||
});
|
||||
const retryBtn = screen.getByRole("button", { name: /Повторить/ });
|
||||
await fireEvent.click(retryBtn);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
// #endregion Test.AgentChat.ToolCallCard.Ux.ReadTimeout
|
||||
|
||||
// #region Test.AgentChat.ToolCallCard.Ux.WriteTimeout [C:2] [TYPE Test]
|
||||
// @UX_STATE timeout (write tool) — Superset status message, NO retry button.
|
||||
describe("ToolCallCard — write timeout state", () => {
|
||||
it("shows Superset status message", () => {
|
||||
const { container } = render(ToolCallCard, {
|
||||
props: {
|
||||
tool: "migrate_dashboard",
|
||||
status: "timeout",
|
||||
timeoutSeconds: 30,
|
||||
isWriteTool: true,
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toContain("Проверьте статус операции в Superset");
|
||||
});
|
||||
|
||||
it("does NOT show retry button", () => {
|
||||
render(ToolCallCard, {
|
||||
props: {
|
||||
tool: "migrate_dashboard",
|
||||
status: "timeout",
|
||||
isWriteTool: true,
|
||||
},
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /Повторить/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
// #endregion Test.AgentChat.ToolCallCard.Ux.WriteTimeout
|
||||
|
||||
// #region Test.AgentChat.ToolCallCard.Ux.Cancelled [C:2] [TYPE Test]
|
||||
// @UX_STATE cancelled — 🚫 icon and user-cancelled message.
|
||||
describe("ToolCallCard — cancelled state", () => {
|
||||
it("shows cancelled icon and message", () => {
|
||||
const { container } = render(ToolCallCard, {
|
||||
props: {
|
||||
tool: "search_dashboards",
|
||||
status: "cancelled",
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toContain("🚫");
|
||||
expect(container.textContent).toContain("Отменено пользователем");
|
||||
});
|
||||
});
|
||||
// #endregion Test.AgentChat.ToolCallCard.Ux.Cancelled
|
||||
// #endregion Test.AgentChat.ToolCallCard.Ux
|
||||
@@ -61,6 +61,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { log } from "$lib/cot-logger";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { ROUTES } from "$lib/routes";
|
||||
import { api } from "$lib/api.js";
|
||||
import { getReports } from "$lib/api/reports.js";
|
||||
@@ -74,7 +75,6 @@
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { auth } from "$lib/auth/store.svelte.js";
|
||||
import { hasPermission } from "$lib/auth/permissions.js";
|
||||
import { toggleAssistantChat } from "$lib/stores/assistantChat.svelte.js";
|
||||
import { Icon } from "$lib/ui";
|
||||
import LanguageSwitcher from "$lib/ui/LanguageSwitcher.svelte";
|
||||
import {
|
||||
@@ -115,16 +115,12 @@
|
||||
$environmentContextStore?.selectedEnvId || "",
|
||||
);
|
||||
let globalSelectedEnv = $derived($selectedEnvironmentStore);
|
||||
let selectedEnvironmentValue = $state("");
|
||||
let selectedEnvironmentValue = $derived(globalSelectedEnvId);
|
||||
let isProdContext = $derived(
|
||||
String(globalSelectedEnv?.stage || "").toUpperCase() === "PROD" ||
|
||||
Boolean(globalSelectedEnv?.is_production),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
selectedEnvironmentValue = globalSelectedEnvId;
|
||||
});
|
||||
|
||||
function toggleUserMenu(event) {
|
||||
event.stopPropagation();
|
||||
showUserMenu = !showUserMenu;
|
||||
@@ -152,7 +148,11 @@
|
||||
}
|
||||
|
||||
function handleAssistantClick() {
|
||||
goto("/agent");
|
||||
const query = [
|
||||
`route=${encodeURIComponent($page.url.pathname)}`,
|
||||
globalSelectedEnvId ? `envId=${encodeURIComponent(globalSelectedEnvId)}` : "",
|
||||
].filter(Boolean).join("&");
|
||||
goto(`/agent?${query}`);
|
||||
}
|
||||
|
||||
async function hydrateTaskDrawerPreference() {
|
||||
@@ -505,12 +505,13 @@
|
||||
|
||||
<!-- Assistant -->
|
||||
<button
|
||||
class="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface-muted"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-border-strong bg-surface-card px-3 py-1.5 text-xs font-medium text-text transition hover:bg-primary hover:text-white hover:border-primary"
|
||||
onclick={handleAssistantClick}
|
||||
aria-label={$t.assistant?.open}
|
||||
title={$t.assistant?.title}
|
||||
>
|
||||
<Icon name="clipboard" size={22} />
|
||||
<Icon name="aiAssistant" size={16} />
|
||||
<span>{$t.assistant?.assistant || "Ассистент"}</span>
|
||||
</button>
|
||||
|
||||
<!-- Activity Indicator -->
|
||||
|
||||
@@ -81,6 +81,14 @@ describe("sidebarNavigation", () => {
|
||||
const sectionIds = sections.map((s) => s.id);
|
||||
|
||||
expect(sectionIds).toEqual(["resources", "operations", "system"]);
|
||||
|
||||
const resources = sections.find((section) => section.id === "resources");
|
||||
expect(resources?.categories.map((category) => category.id)).toEqual([
|
||||
"dashboards",
|
||||
"datasets",
|
||||
"migration",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows only categories available to a non-admin user", () => {
|
||||
@@ -106,6 +114,7 @@ describe("sidebarNavigation", () => {
|
||||
"dashboards",
|
||||
"datasets",
|
||||
"migration",
|
||||
"assistant",
|
||||
"git",
|
||||
"reports",
|
||||
"tools",
|
||||
|
||||
@@ -129,10 +129,20 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
|
||||
{ label: nav.migration_mappings || "Mappings", path: "/migration/mappings", requiredPermission: "plugin:migration", requiredAction: "READ", requiredFeature: "migration" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "assistant",
|
||||
label: nav.ai_assistant || "AI Ассистент",
|
||||
icon: "aiAssistant",
|
||||
tone: "from-category-assistant-from to-category-assistant-to text-category-assistant-text ring-category-assistant-ring",
|
||||
path: "/agent",
|
||||
subItems: [
|
||||
{ label: nav.ai_assistant || "AI Ассистент", path: "/agent" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── Section 2: Operations ────────────────────────────────
|
||||
// ── Section 2: Operations ──────────────────────────────────
|
||||
const operations: Section = {
|
||||
id: "operations",
|
||||
label: nav.section_operations,
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"section_operations": "Operations",
|
||||
"section_system": "System",
|
||||
|
||||
"ai_assistant": "AI Assistant",
|
||||
|
||||
"dashboards": "Dashboards",
|
||||
"datasets": "Datasets",
|
||||
"overview": "Overview",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"section_operations": "Операции",
|
||||
"section_system": "Система",
|
||||
|
||||
"ai_assistant": "AI Ассистент",
|
||||
|
||||
"dashboards": "Дашборды",
|
||||
"datasets": "Датасеты",
|
||||
"overview": "Обзор",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
StreamingState,
|
||||
StreamMetadata,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
AgentMessage,
|
||||
} from "./AgentChatTypes.js";
|
||||
|
||||
@@ -32,15 +33,21 @@ export interface StreamProcessorHost {
|
||||
pendingToolArgs: Record<string, unknown>;
|
||||
pendingConfirmationRisk: "read" | "write" | "unknown" | null;
|
||||
pendingRiskLevel: string | null;
|
||||
permissionAlternatives: Array<{ action: string; prompt: string }> | null;
|
||||
startDangerousCountdown(): void;
|
||||
/** Populated by file_uploaded metadata — attached to user message on stream end. */
|
||||
_pendingAttachment: { file_name: string; file_path: string; file_size: number } | null;
|
||||
captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void;
|
||||
captureConversationId(_meta: StreamMetadata | undefined, _convIdFromEvent: string): void;
|
||||
}
|
||||
|
||||
// ═══ StreamProcessor ════════════════════════════════════════════
|
||||
|
||||
export class StreamProcessor {
|
||||
constructor(private host: StreamProcessorHost) {}
|
||||
private host: StreamProcessorHost;
|
||||
|
||||
constructor(host: StreamProcessorHost) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
/** Iterate Gradio submit() events and update host state. Returns true when stream completes. */
|
||||
async processStream(
|
||||
@@ -197,6 +204,9 @@ export class StreamProcessor {
|
||||
? meta.risk
|
||||
: null;
|
||||
this.host.pendingRiskLevel = meta.risk_level ?? null;
|
||||
if (meta.risk_level === "dangerous") {
|
||||
this.host.startDangerousCountdown();
|
||||
}
|
||||
break;
|
||||
|
||||
case "confirm_resolved":
|
||||
@@ -206,6 +216,16 @@ export class StreamProcessor {
|
||||
this.host.pendingThreadId = null;
|
||||
this.host.pendingConfirmationRisk = null;
|
||||
this.host.pendingRiskLevel = null;
|
||||
// Push cancelled tool card when user denies
|
||||
if (meta.result === "denied") {
|
||||
this.host.activeToolCalls = [...this.host.activeToolCalls, {
|
||||
tool: this.host.pendingToolName || "unknown_action",
|
||||
status: "cancelled" as ToolCallStatus,
|
||||
input: this.host.pendingToolArgs as Record<string, unknown>,
|
||||
}];
|
||||
this.host.pendingToolName = null;
|
||||
this.host.pendingToolArgs = {};
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
@@ -229,6 +249,26 @@ export class StreamProcessor {
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_retry": {
|
||||
const toolCall = this.host.activeToolCalls.find(t => t.tool === meta.tool);
|
||||
if (toolCall) {
|
||||
toolCall.status = "retrying" as ToolCallStatus;
|
||||
toolCall.retryCount = meta.attempt;
|
||||
toolCall.maxRetries = meta.max_attempts;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_timeout": {
|
||||
const toolCall = this.host.activeToolCalls.find(t => t.tool === meta.tool);
|
||||
if (toolCall) {
|
||||
toolCall.status = "timeout" as ToolCallStatus;
|
||||
toolCall.timeoutSeconds = meta.timeout_seconds;
|
||||
toolCall.isWriteTool = meta.is_write_tool || false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "file_uploaded":
|
||||
if (meta.file_name && meta.file_path) {
|
||||
this.host._pendingAttachment = {
|
||||
@@ -239,6 +279,19 @@ export class StreamProcessor {
|
||||
}
|
||||
break;
|
||||
|
||||
case "permission_denied": {
|
||||
// Render a terminal access-denied card without entering a HITL checkpoint.
|
||||
this.host.streamingState = "awaiting_confirmation";
|
||||
this.host.pendingThreadId = this.host.currentConversationId ?? "permission-denied";
|
||||
this.host.confirmationMessage = msg.text || "Недостаточно прав";
|
||||
this.host.pendingToolName = meta.tool_name ?? null;
|
||||
this.host.pendingToolArgs = {};
|
||||
this.host.pendingConfirmationRisk = "unknown";
|
||||
this.host.pendingRiskLevel = "permission_denied";
|
||||
this.host.permissionAlternatives = meta.alternatives ?? null;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
log("AgentChat.StreamProcessor", "EXPLORE", "Unknown metadata type", { type: meta.type }, "Unhandled metadata type " + meta.type);
|
||||
}
|
||||
|
||||
@@ -27,10 +27,7 @@
|
||||
// @REJECTED WebSocket-based model — rejected with custom WebSocket protocol.
|
||||
// @REJECTED Active/follower multi-tab — rejected in favor of client-side gate.
|
||||
import { type Client as GradioClient } from "@gradio/client";
|
||||
import {
|
||||
assistantChatStore,
|
||||
setAssistantConversationId,
|
||||
} from "$lib/stores/assistantChat.svelte.js";
|
||||
import { setAssistantConversationId } from "$lib/stores/assistantChat.svelte.js";
|
||||
import {
|
||||
getAssistantConversations,
|
||||
getAssistantHistory,
|
||||
@@ -43,7 +40,7 @@ import { type ConnectionManagerCallbacks, ConnectionManager } from "./AgentChat.
|
||||
import { type StreamProcessorHost, StreamProcessor } from "./AgentChat.StreamProcessor.svelte.js";
|
||||
import { LocalStorageManager } from "./AgentChat.LocalStorage.js";
|
||||
import type {
|
||||
StreamingState, ConnectionState, StreamMetadata, ToolCall, AgentMessage, Conversation,
|
||||
StreamingState, ConnectionState, StreamMetadata, ToolCall, AgentMessage, Conversation, UIContext,
|
||||
} from "./AgentChatTypes.js";
|
||||
|
||||
// ═══ Main model ═════════════════════════════════════════════════
|
||||
@@ -111,6 +108,12 @@ export class AgentChatModel {
|
||||
userId: string = $state("");
|
||||
userJwt: string = $state("");
|
||||
envId: string = $state("");
|
||||
// ── UI Context (035-agent-chat-context) ────────────────────────
|
||||
uiContext: UIContext | null = $state(null);
|
||||
dangerousCountdown: number = $state(0);
|
||||
dangerousCountdownActive: boolean = $state(false);
|
||||
_activeEnvId: string | null = $state(null);
|
||||
permissionAlternatives: Array<{ action: string; prompt: string }> | null = $state(null);
|
||||
|
||||
// ── Private fields ─────────────────────────────────────────────
|
||||
_client: GradioClient | null = null; // non-private for legacy Object.assign usage
|
||||
@@ -215,8 +218,32 @@ export class AgentChatModel {
|
||||
if (this.hasSilentStream) return "Если upstream LLM не начнет отвечать, чат покажет восстановление вместо бесконечного ожидания.";
|
||||
if (this.streamingState === "streaming") return "Ответ поступает по стриму.";
|
||||
if (this.envId) return `Контекст готов: окружение ${this.envId}.`;
|
||||
if (this._activeEnvId) return `Окружение: ${this._activeEnvId}.`;
|
||||
if (this.uiContext) {
|
||||
const type = this.uiContext.objectType ? ` ${this.uiContext.objectType}#${this.uiContext.objectId}` : "";
|
||||
const env = this.uiContext.envId ? ` · ${this.uiContext.envId}` : "";
|
||||
return `Контекст${type}${env}.`;
|
||||
}
|
||||
return "Выберите окружение перед задачами, которые работают с Superset.";
|
||||
});
|
||||
contextPillLabel = $derived.by((): string => {
|
||||
if (!this.uiContext) {
|
||||
return this._activeEnvId ? `🌐 ${this._activeEnvId}` : "⚪ Контекст не выбран";
|
||||
}
|
||||
if (!this.uiContext.objectType) {
|
||||
return this._activeEnvId ? `🌐 ${this._activeEnvId}` : "⚪ Контекст не выбран";
|
||||
}
|
||||
const typeIcons: Record<string, string> = { dashboard: "📋", dataset: "📊", migration: "🔄" };
|
||||
const icon = typeIcons[this.uiContext.objectType] || "📄";
|
||||
const idLabel = this.uiContext.objectId ? ` #${this.uiContext.objectId}` : "";
|
||||
const env = this._activeEnvId || this.uiContext.envId || "";
|
||||
return `${icon} ${this.uiContext.objectType}${idLabel} · ${env}`;
|
||||
});
|
||||
isProdContext = $derived(
|
||||
(this.uiContext?.envId?.toLowerCase().includes("prod") || false) ||
|
||||
(this._activeEnvId?.toLowerCase().includes("prod") || false)
|
||||
);
|
||||
showProdWarning = $derived(this.isProdContext);
|
||||
processSteps = $derived.by((): AgentProcessStep[] => {
|
||||
const hasTools = this.activeToolCalls.length > 0 || this.messages.some((msg) => (msg.toolCalls || []).length > 0);
|
||||
const hasAssistantResult = this.messages.some((msg) => msg.role === "assistant" && msg.text.trim().length > 0);
|
||||
@@ -224,8 +251,8 @@ export class AgentChatModel {
|
||||
return [
|
||||
{
|
||||
id: "context",
|
||||
label: this.envId ? `Контекст: ${this.envId}` : "Контекст",
|
||||
state: this.envId ? "done" : failed ? "blocked" : "idle",
|
||||
label: this.contextPillLabel,
|
||||
state: (this.uiContext || this._activeEnvId) ? "done" : failed ? "blocked" : "idle",
|
||||
},
|
||||
{
|
||||
id: "reasoning",
|
||||
@@ -234,13 +261,15 @@ export class AgentChatModel {
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Инструменты",
|
||||
state: this.activeToolCalls.some((tool) => tool.status === "executing") ? "active" : hasTools ? "done" : "idle",
|
||||
label: this.activeToolCalls.some((t) => t.status === "executing" || t.status === "retrying")
|
||||
? `Инструменты (${this.activeToolCalls.filter((t) => t.status === "executing" || t.status === "retrying").length})`
|
||||
: "Инструменты",
|
||||
state: this.activeToolCalls.some((t) => t.status === "executing" || t.status === "retrying") ? "active" : hasTools ? "done" : "idle",
|
||||
},
|
||||
{
|
||||
id: "confirmation",
|
||||
label: "Подтверждение",
|
||||
state: this.streamingState === "awaiting_confirmation" ? "active" : this.pendingThreadId ? "done" : "idle",
|
||||
label: this.pendingRiskLevel === "permission_denied" ? "Нет доступа" : "Подтверждение",
|
||||
state: this.streamingState === "awaiting_confirmation" ? "active" : this.pendingRiskLevel === "permission_denied" ? "blocked" : this.pendingThreadId ? "done" : "idle",
|
||||
},
|
||||
{
|
||||
id: "result",
|
||||
@@ -301,13 +330,13 @@ export class AgentChatModel {
|
||||
confirmationMessageOverride = $derived.by(() => {
|
||||
const message = (this.confirmationMessage || "").trim();
|
||||
if (!message) return "";
|
||||
const genericMessages = new Set([
|
||||
const genericMessages = [
|
||||
"Подтвердить операцию?",
|
||||
"Подтвердите действие",
|
||||
"Confirm operation?",
|
||||
"Confirm action",
|
||||
]);
|
||||
return genericMessages.has(message) ? "" : message;
|
||||
];
|
||||
return genericMessages.includes(message) ? "" : message;
|
||||
});
|
||||
pendingToolLabel = $derived.by(() => {
|
||||
if (!this.pendingToolName) return "";
|
||||
@@ -408,6 +437,57 @@ export class AgentChatModel {
|
||||
await this.sendMessage(text);
|
||||
}
|
||||
|
||||
setUIContextFromParams(params: URLSearchParams): void {
|
||||
const rawType = params.get("objectType");
|
||||
const validTypes = ["dashboard", "dataset", "migration"];
|
||||
const objectType = (rawType && validTypes.includes(rawType))
|
||||
? (rawType as UIContext["objectType"]) : null;
|
||||
const objectId = params.get("objectId") || null;
|
||||
const objectName = params.get("objectName") || null;
|
||||
const envId = params.get("envId") || null;
|
||||
const route = params.get("route") || "/agent";
|
||||
|
||||
this.uiContext = { objectType, objectId, objectName, envId, route, contextVersion: 1 };
|
||||
this._activeEnvId = envId;
|
||||
log("AgentChat.Model", "REASON", "UI context set from params", { objectType, objectId, envId });
|
||||
}
|
||||
|
||||
updateActiveEnv(envId: string | null): void {
|
||||
this._activeEnvId = envId;
|
||||
if (this.uiContext) {
|
||||
this.uiContext.envId = envId;
|
||||
}
|
||||
}
|
||||
|
||||
serializedUIContext(): string | null {
|
||||
return this.uiContext ? JSON.stringify(this.uiContext) : null;
|
||||
}
|
||||
|
||||
/** Start 10s countdown for dangerous tool confirmation. Model owns the timer. */
|
||||
startDangerousCountdown(): void {
|
||||
this.dangerousCountdownActive = false;
|
||||
this.dangerousCountdown = 10;
|
||||
this.dangerousCountdownActive = true;
|
||||
const interval = setInterval(() => {
|
||||
this.dangerousCountdown--;
|
||||
if (this.dangerousCountdown <= 0) {
|
||||
this.dangerousCountdownActive = false;
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
dismissPermissionDenied(): void {
|
||||
this.streamingState = "idle";
|
||||
this.pendingThreadId = null;
|
||||
this.confirmationMessage = null;
|
||||
this.pendingToolName = null;
|
||||
this.pendingToolArgs = {};
|
||||
this.pendingConfirmationRisk = null;
|
||||
this.pendingRiskLevel = null;
|
||||
this.permissionAlternatives = null;
|
||||
}
|
||||
|
||||
async sendMessage(text: string, files?: File[]): Promise<void> {
|
||||
// Sync reactive values from the page component
|
||||
this.onBeforeSend?.();
|
||||
@@ -452,7 +532,7 @@ export class AgentChatModel {
|
||||
return;
|
||||
}
|
||||
this._submission = this._client.submit("chat",
|
||||
[{ text, files }, null, convId, null, this.userId, this.userJwt, this.envId],
|
||||
[{ text, files }, null, convId, null, this.userId, this.userJwt, this.envId, this.serializedUIContext()],
|
||||
);
|
||||
|
||||
// Process stream events with a 180s safety timeout.
|
||||
@@ -563,6 +643,7 @@ export class AgentChatModel {
|
||||
role: "assistant",
|
||||
text,
|
||||
toolCalls,
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
@@ -583,6 +664,7 @@ export class AgentChatModel {
|
||||
role: "assistant",
|
||||
text: "⏹️ Операция отменена",
|
||||
toolCalls: [],
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
@@ -622,7 +704,16 @@ export class AgentChatModel {
|
||||
return;
|
||||
}
|
||||
this._submission = this._client.submit("chat",
|
||||
[{ text: action === "confirm" ? "confirm" : "deny", files: [] }, null, this.currentConversationId, action, this.userId, this.userJwt, this.envId],
|
||||
[
|
||||
{ text: action === "confirm" ? "confirm" : "deny", files: [] },
|
||||
null,
|
||||
this.currentConversationId,
|
||||
action,
|
||||
this.userId,
|
||||
this.userJwt,
|
||||
this.envId,
|
||||
this.serializedUIContext(),
|
||||
],
|
||||
);
|
||||
|
||||
// Safety timeout: same pattern as _sendNow — prevents hanging
|
||||
@@ -686,8 +777,8 @@ export class AgentChatModel {
|
||||
} else {
|
||||
// Defensive dedup: skip items already present (prevents duplicates
|
||||
// from overlapping API pages or stale concurrent calls).
|
||||
const existingIds = new Set(this.conversations.map(c => c.id));
|
||||
const newItems = mapped.filter(item => !existingIds.has(item.id));
|
||||
const existingIds = this.conversations.map(c => c.id);
|
||||
const newItems = mapped.filter(item => !existingIds.includes(item.id));
|
||||
this.conversations = [...this.conversations, ...newItems];
|
||||
}
|
||||
this._conversationsHasNext = Boolean(res.has_next);
|
||||
|
||||
@@ -19,8 +19,9 @@ export type ConnectionState =
|
||||
|
||||
export interface StreamMetadata {
|
||||
type?: "stream_token" | "tool_start" | "tool_end" | "tool_error"
|
||||
| "tool_retry" | "tool_timeout"
|
||||
| "confirm_required" | "confirm_resolved" | "error"
|
||||
| "file_uploaded";
|
||||
| "file_uploaded" | "permission_denied";
|
||||
token?: string;
|
||||
tool?: string;
|
||||
input?: Record<string, unknown>;
|
||||
@@ -31,6 +32,12 @@ export interface StreamMetadata {
|
||||
result?: "confirmed" | "denied";
|
||||
code?: string;
|
||||
detail?: string;
|
||||
/** tool_retry fields */
|
||||
attempt?: number;
|
||||
max_attempts?: number;
|
||||
/** tool_timeout fields */
|
||||
timeout_seconds?: number;
|
||||
is_write_tool?: boolean;
|
||||
/** Tool name being requested for confirmation (HITL). */
|
||||
tool_name?: string;
|
||||
/** Tool arguments for the pending confirmation call. */
|
||||
@@ -47,14 +54,28 @@ export interface StreamMetadata {
|
||||
file_path?: string;
|
||||
/** file_uploaded: file size in bytes. */
|
||||
file_size?: number;
|
||||
/** permission_denied: alternative actions the user can request instead. */
|
||||
alternatives?: Array<{ action: string; prompt: string }> | null;
|
||||
}
|
||||
|
||||
export type ToolCallStatus =
|
||||
| "executing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "retrying"
|
||||
| "timeout"
|
||||
| "cancelled";
|
||||
|
||||
export interface ToolCall {
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
status: "executing" | "completed" | "failed";
|
||||
status: ToolCallStatus;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
timeoutSeconds?: number;
|
||||
isWriteTool?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentMessage {
|
||||
@@ -81,4 +102,33 @@ export interface Conversation {
|
||||
updated_at: string;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface UIContext {
|
||||
objectType: "dashboard" | "dataset" | "migration" | null;
|
||||
objectId: string | null;
|
||||
objectName: string | null;
|
||||
envId: string | null;
|
||||
route: string;
|
||||
contextVersion: number;
|
||||
}
|
||||
|
||||
export interface PermissionDeniedMetadata {
|
||||
type: "permission_denied";
|
||||
tool_name: string;
|
||||
required_role: string;
|
||||
user_role: string;
|
||||
alternatives: Array<{ action: string; prompt: string }> | null;
|
||||
}
|
||||
|
||||
export interface ConfirmMetadataV2 {
|
||||
type: "confirm_required";
|
||||
thread_id: string;
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
risk: "read" | "write";
|
||||
risk_level: "safe" | "guarded" | "dangerous";
|
||||
dangerous: boolean;
|
||||
env_context: "prod" | "staging" | "dev" | null;
|
||||
prompt?: string;
|
||||
}
|
||||
// #endregion AgentChat.Types
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"fixture_id": "FX_AgentChat.SetContext.FullParams",
|
||||
"verifies": "AgentChat.Model.SetContext",
|
||||
"edge": "full_params",
|
||||
"input": {
|
||||
"params": {
|
||||
"objectType": "dashboard",
|
||||
"objectId": "42",
|
||||
"objectName": "Отчёт по энергопотреблению",
|
||||
"envId": "ss-dev",
|
||||
"route": "/dashboards/42"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"uiContext": {
|
||||
"objectType": "dashboard",
|
||||
"objectId": "42",
|
||||
"objectName": "Отчёт по энергопотреблению",
|
||||
"envId": "ss-dev",
|
||||
"route": "/dashboards/42",
|
||||
"contextVersion": 1
|
||||
},
|
||||
"contextPillLabel": "📋 dashboard #42 · ss-dev",
|
||||
"isProdContext": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"fixture_id": "FX_AgentChat.SetContext.NoParams",
|
||||
"verifies": "AgentChat.Model.SetContext",
|
||||
"edge": "no_params",
|
||||
"input": {
|
||||
"params": {}
|
||||
},
|
||||
"expected": {
|
||||
"uiContext": null,
|
||||
"contextPillLabel": "⚪ Контекст не выбран",
|
||||
"isProdContext": false
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,28 @@ describe("AgentChatModel — State Machine", () => {
|
||||
expect(model.streamingState).toBe("streaming");
|
||||
await sendPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it("sendMessage passes serialized UIContext as the final Gradio input", async () => {
|
||||
model.setUIContextFromParams(new URLSearchParams({
|
||||
objectType: "dashboard",
|
||||
objectId: "42",
|
||||
objectName: "Energy",
|
||||
envId: "ss-dev",
|
||||
route: "/dashboards/42",
|
||||
}));
|
||||
await model.sendMessage("context check").catch(() => {});
|
||||
const submit = model._client?.submit as ReturnType<typeof vi.fn>;
|
||||
const args = submit.mock.calls[0][1] as unknown[];
|
||||
expect(args).toHaveLength(8);
|
||||
expect(JSON.parse(args[7] as string)).toMatchObject({
|
||||
objectType: "dashboard",
|
||||
objectId: "42",
|
||||
objectName: "Energy",
|
||||
envId: "ss-dev",
|
||||
route: "/dashboards/42",
|
||||
contextVersion: 1,
|
||||
});
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region TestAgentChat.Model.SendMessageGuardEmpty [C:2] [TYPE Test]
|
||||
@@ -451,6 +473,37 @@ describe("AgentChatModel — Metadata Handling", () => {
|
||||
expect(model.confirmationRisk).toBe("write");
|
||||
});
|
||||
|
||||
it("starts countdown for dangerous confirmation metadata", () => {
|
||||
model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "",
|
||||
metadata: {
|
||||
type: "confirm_required",
|
||||
thread_id: "thread-1",
|
||||
tool_name: "delete_dashboard",
|
||||
risk: "write",
|
||||
risk_level: "dangerous",
|
||||
} });
|
||||
expect(model.streamingState).toBe("awaiting_confirmation");
|
||||
expect(model.dangerousCountdownActive).toBe(true);
|
||||
expect(model.dangerousCountdown).toBe(10);
|
||||
});
|
||||
|
||||
it("renders permission_denied metadata as dismiss-only card state", () => {
|
||||
model.currentConversationId = "conv-denied";
|
||||
model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "conv-denied", role: "assistant", text: "Недостаточно прав", toolCalls: [], created_at: "",
|
||||
metadata: {
|
||||
type: "permission_denied",
|
||||
tool_name: "deploy_dashboard",
|
||||
alternatives: [{ action: "search_dashboards", prompt: "Найти дашборды" }],
|
||||
} });
|
||||
expect(model.streamingState).toBe("awaiting_confirmation");
|
||||
expect(model.pendingRiskLevel).toBe("permission_denied");
|
||||
expect(model.pendingToolName).toBe("deploy_dashboard");
|
||||
expect(model.permissionAlternatives).toHaveLength(1);
|
||||
model.dismissPermissionDenied();
|
||||
expect(model.streamingState).toBe("idle");
|
||||
expect(model.pendingRiskLevel).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to current conversation id when confirm_required lacks thread_id", () => {
|
||||
model.currentConversationId = "conv-fallback";
|
||||
model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "conv-fallback", role: "assistant", text: "", toolCalls: [], created_at: "",
|
||||
|
||||
@@ -165,6 +165,32 @@
|
||||
"M10 14L21 3",
|
||||
],
|
||||
arrowRight: ["M5 12h14", "M12 5l7 7-7 7"],
|
||||
sparkles: [
|
||||
"M14 4l2.5 2.5L19 4l-2.5 2.5L19 9l-2.5-2.5L14 9l2.5-2.5L14 4z",
|
||||
"M5 12l2 2 3-3-3 3 2 2-2-2-3 3 3-3-2-2z",
|
||||
"M12 19l1.5-1.5L15 19l-1.5 1.5L15 22l-1.5-1.5L12 22l1.5-1.5L12 19z",
|
||||
],
|
||||
aiAssistant: [
|
||||
"M8 8V6.8a4 4 0 018 0V8",
|
||||
"M7 8h10a3 3 0 013 3v4a3 3 0 01-3 3h-3.4L10 21v-3H7a3 3 0 01-3-3v-4a3 3 0 013-3z",
|
||||
"M9.5 13h.01",
|
||||
"M14.5 13h.01",
|
||||
"M10 16h4",
|
||||
"M18.5 3l.8 1.7 1.7.8-1.7.8-.8 1.7-.8-1.7-1.7-.8 1.7-.8.8-1.7z",
|
||||
"M5 3.8l.6 1.3 1.4.7-1.4.7L5 7.8l-.6-1.3L3 5.8l1.4-.7L5 3.8z",
|
||||
],
|
||||
brain: [
|
||||
"M12 4a4 4 0 014 4c0 1.5-.8 2.8-2 3.4v.6c0 .8-.7 1.5-1.5 1.5h-1c-.8 0-1.5-.7-1.5-1.5v-.6c-1.2-.6-2-1.9-2-3.4a4 4 0 014-4z",
|
||||
"M9 15v1a3 3 0 003 3",
|
||||
"M12 16c1.7 0 3-1.3 3-3v-1",
|
||||
],
|
||||
cpu: [
|
||||
"M9 3h6v2H9z",
|
||||
"M9 19h6v2H9z",
|
||||
"M3 9h2v6H3z",
|
||||
"M19 9h2v6h-2z",
|
||||
"M5 5h14v14H5z",
|
||||
],
|
||||
};
|
||||
|
||||
let paths = $derived(iconPaths[name] || iconPaths.dashboard);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<!-- @REJECTED SvelteKit `load()` function rejected — Gradio client connection is browser-only (Client.connect) and cannot run during SSR. Inline sidebar toggle state in page (not delegated to model) rejected — ConversationList open/close is a cross-widget concern tied to the agent chat layout, not page-level UI. <Button> component from $lib/ui rejected for the sidebar toggle chrome control (line 102) — the w-6 h-12 shape + border-radius combination is too custom for Button's variant system; raw <button> is justified by @RATIONALE inline. -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { browser } from "$app/environment";
|
||||
import { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts";
|
||||
import { Client } from "@gradio/client";
|
||||
@@ -22,13 +23,11 @@
|
||||
|
||||
let sidebarExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
|
||||
|
||||
// Get current user identity + JWT for Gradio conversation persistence and tool auth
|
||||
// $auth is Svelte auto-subscription syntax for stores with .subscribe()
|
||||
let currentUserId = $derived($auth.user?.id ?? "");
|
||||
let currentUserJwt = $derived($auth.token ?? "");
|
||||
let currentEnvironmentId = $derived(
|
||||
$environmentContextStore?.selectedEnvId || (browser ? localStorage.getItem("selected_env_id") || "" : ""),
|
||||
);
|
||||
let currentPageSearchString = $derived($page.url.search);
|
||||
|
||||
// Guard against double initialization in dev HMR
|
||||
let _initialized = false;
|
||||
|
||||
@@ -37,6 +36,7 @@
|
||||
m.userId = $auth.user?.id ?? "";
|
||||
m.userJwt = $auth.token ?? "";
|
||||
m.envId = envId;
|
||||
m.updateActiveEnv(envId || null);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -51,6 +51,9 @@
|
||||
const m = new AgentChatModel();
|
||||
m.onBeforeSend = () => syncModel(m);
|
||||
syncModel(m);
|
||||
// Read UIContext from URL params (035-agent-chat-context)
|
||||
const params = new URLSearchParams(currentPageSearchString);
|
||||
m.setUIContextFromParams(params);
|
||||
const unsubscribeEnvironment = environmentContextStore.subscribe((state) => {
|
||||
syncModel(m, state?.selectedEnvId || (browser ? localStorage.getItem("selected_env_id") || "" : ""));
|
||||
});
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
runBackupTask,
|
||||
loadDashboardPage
|
||||
} = $props();
|
||||
|
||||
let agentHref = $derived.by(() => {
|
||||
const objectId = String(dashboardRef || resolvedDashboardId || "");
|
||||
const params = new URLSearchParams({
|
||||
objectType: "dashboard",
|
||||
objectId,
|
||||
objectName: dashboard?.title || "",
|
||||
envId: envId || "",
|
||||
route: `/dashboards/${objectId}`,
|
||||
});
|
||||
return `/agent?${params.toString()}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
@@ -82,6 +94,14 @@
|
||||
>
|
||||
{isStartingBackup ? $t.common?.loading : $t.dashboard?.run_backup}
|
||||
</button>
|
||||
<a
|
||||
href={agentHref}
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border-strong bg-surface-card px-4 py-2 text-sm font-medium text-text transition-colors hover:bg-primary hover:text-white"
|
||||
title="Спросить AI о дашборде"
|
||||
>
|
||||
<Icon name="aiAssistant" size={16} />
|
||||
<span>AI</span>
|
||||
</a>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover"
|
||||
onclick={loadDashboardPage}
|
||||
@@ -90,4 +110,4 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion DashboardHeader -->
|
||||
<!-- #endregion DashboardHeader -->
|
||||
|
||||
@@ -19,8 +19,17 @@
|
||||
|
||||
let datasetId = $derived(page.params.id);
|
||||
let envId = $derived(page.url.searchParams.get('env_id') || '');
|
||||
|
||||
const model = new DatasetDetailModel();
|
||||
let agentHref = $derived.by(() => {
|
||||
const params = new URLSearchParams({
|
||||
objectType: "dataset",
|
||||
objectId: datasetId,
|
||||
objectName: model.dataset?.table_name || "",
|
||||
envId,
|
||||
route: `/datasets/${datasetId}`,
|
||||
});
|
||||
return `/agent?${params.toString()}`;
|
||||
});
|
||||
$effect(() => { model.datasetId = datasetId; });
|
||||
$effect(() => { model.envId = envId; });
|
||||
|
||||
@@ -46,9 +55,19 @@
|
||||
<h1 class="text-2xl font-bold text-text mt-4">{$t.datasets?.detail_title}</h1>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="destructive" onclick={() => model.loadDatasetDetail()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href={agentHref}
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border-strong bg-surface-card px-4 py-2 text-sm font-medium text-text transition-colors hover:bg-primary hover:text-white"
|
||||
title="Спросить AI о датасете"
|
||||
>
|
||||
<Icon name="aiAssistant" size={16} />
|
||||
<span>AI</span>
|
||||
</a>
|
||||
<Button variant="destructive" onclick={() => model.loadDatasetDetail()}>
|
||||
{$t.common?.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Banner -->
|
||||
|
||||
Reference in New Issue
Block a user