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:
2026-07-04 22:47:17 +03:00
parent 4c5fde8b5c
commit ff60865183
82 changed files with 5168 additions and 553 deletions

View File

@@ -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>

View File

@@ -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"}

View File

@@ -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">
Подтвердить &middot; Отклонить
{isPermissionDenied ? "Esc Закрыть" : "Enter Подтвердить · Esc Отклонить"}
</p>
</div>
</div>

View File

@@ -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}

View File

@@ -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

View File

@@ -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 -->

View File

@@ -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",

View File

@@ -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,

View File

@@ -9,6 +9,8 @@
"section_operations": "Operations",
"section_system": "System",
"ai_assistant": "AI Assistant",
"dashboards": "Dashboards",
"datasets": "Datasets",
"overview": "Overview",

View File

@@ -9,6 +9,8 @@
"section_operations": "Операции",
"section_system": "Система",
"ai_assistant": "AI Ассистент",
"dashboards": "Дашборды",
"datasets": "Датасеты",
"overview": "Обзор",

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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: "",

View File

@@ -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);

View File

@@ -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") || "" : ""));
});

View File

@@ -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 -->

View File

@@ -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 -->