Files
ss-tools/specs/035-agent-chat-context/contracts/ux/model-changes.md
busya ff60865183 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
2026-07-04 22:47:17 +03:00

578 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#region ModelChanges [C:3] [TYPE ADR] [SEMANTICS ux,model-changes,agent-chat]
@defgroup Ux Precise edit instructions for existing model/component files. Implementation target for /speckit.implement.
## File 1: `frontend/src/lib/models/AgentChatTypes.ts`
**ADD** after existing types:
```typescript
// ── UI Context (035-agent-chat-context) ─────────────────────────
/** Structured context about the user's current page — passed from frontend to agent on each message. */
export interface UIContext {
objectType: "dashboard" | "dataset" | "migration" | null;
objectId: string | null;
objectName: string | null;
envId: string | null;
route: string;
contextVersion: number;
}
/** Maps objectType to tool operation names — server-side, shown in debug panel. */
export interface ToolAffinityEntry {
operation: string;
active: boolean; // true = tool available for this context
reason?: string; // why excluded (e.g. "dataset tool")
}
/** Extended confirmation metadata from backend SSE. */
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;
permission_granted: boolean;
required_role: string | null;
alternatives: Array<{ action: string; prompt: string }> | null;
prompt?: string;
}
```
**EXTEND** `ToolCall` interface — add `"retrying" | "timeout" | "cancelled"` to `status` union:
```typescript
export interface ToolCall {
id: string;
name: string;
status: "executing" | "retrying" | "completed" | "failed" | "timeout" | "cancelled";
input?: Record<string, unknown>;
output?: { result: string };
error?: string;
retryCount?: number;
maxRetries?: number;
timeoutSeconds?: number;
}
```
**EXTEND** `StreamingState` union — add `"disconnected_permanent"` (already exists in model, ensure type includes it):
```typescript
export type StreamingState = "idle" | "streaming" | "awaiting_confirmation" | "error" | "disconnected" | "disconnected_permanent";
```
---
## File 2: `frontend/src/lib/models/AgentChatModel.svelte.ts`
### 2a. ADD import (top of file):
```typescript
import type { UIContext, ToolAffinityEntry, ConfirmMetadataV2 } from "./AgentChatTypes.js";
```
### 2b. ADD atoms (after existing `envId` atom, ~line 113):
```typescript
// ── UI Context (035-agent-chat-context) ────────────────────────
uiContext: UIContext | null = $state(null);
toolAffinity: ToolAffinityEntry[] = $state([]);
dangerousCountdown: number = $state(0);
dangerousCountdownActive: boolean = $state(false);
/** Tracks env from EnvSelector (may differ from uiContext.envId during dialog). */
_activeEnvId: string | null = $state(null);
/** Alternatives suggested by backend when permission denied. */
permissionAlternatives: Array<{ action: string; prompt: string }> | null = $state(null);
```
### 2c. ADD derived (after existing derived block, ~line 317):
```typescript
// ── Prod context detection ─────────────────────────────────────
/** True when either uiContext or active env selector is prod. */
isProdContext = $derived(
(this.uiContext?.envId === "prod") || this._activeEnvId === "prod"
);
showProdWarning = $derived(this.isProdContext);
// ── Context pill label (extended, replaces simple envId label) ─
contextPillLabel = $derived.by((): string => {
const env = this._activeEnvId || this.uiContext?.envId || "";
if (!this.uiContext || !this.uiContext.objectType) {
return env ? `🌐 ${env}` : "⚪ Контекст не выбран";
}
const typeIcons: Record<string, string> = {
dashboard: "📋", dataset: "📊", migration: "🔄",
};
const icon = typeIcons[this.uiContext.objectType] || "📄";
const idLabel = this.uiContext.objectId ? ` #${this.uiContext.objectId}` : "";
return `${icon} ${this.uiContext.objectType}${idLabel} · ${env}`;
});
// ── Process steps (extended — first step uses contextPillLabel) ──
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);
const failed = this.streamingState === "error";
return [
{
id: "context",
label: this.contextPillLabel,
state: (this.uiContext || this._activeEnvId) ? (failed ? "blocked" : "done") : (failed ? "blocked" : "idle"),
},
// ... rest of existing steps unchanged ...
];
});
// ── Confirmation risk (extended — use backend metadata) ────────
// НЕ менять существующий $derived — только убедиться что он читает
// pendingConfirmationRisk, pendingRiskLevel из StreamProcessor
```
### 2d. ADD actions (before `sendMessage`, ~line 410):
```typescript
// ── Context actions (035-agent-chat-context) ────────────────────
/** Set UI context from URL params on page mount. */
setUIContextFromParams(params: URLSearchParams): void {
const objectType = params.get("objectType") 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,
});
}
/** Update environment from EnvSelector during dialog. */
updateActiveEnv(envId: string | null): void {
this._activeEnvId = envId;
// Note: envId for tool calls is still passed separately — this tracks for UI only
}
/** Start dangerous operation countdown. Called by StreamProcessor. */
startDangerousCountdown(): void {
this.dangerousCountdown = 10;
this.dangerousCountdownActive = true;
const interval = setInterval(() => {
this.dangerousCountdown--;
if (this.dangerousCountdown <= 0) {
this.dangerousCountdownActive = false;
clearInterval(interval);
}
}, 1000);
}
```
### 2e. UPDATE `resetStreamingState` (internal helper) — add context reset:
```typescript
// В методе, который сбрасывает состояние перед новым стримом:
private _resetForStream(): void {
// ... existing resets ...
this.dangerousCountdown = 0;
this.dangerousCountdownActive = false;
this.permissionAlternatives = null;
}
```
---
## File 3: `frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts`
### 3a. ADD new metadata handlers (in `_handleMetadata` switch):
```typescript
// После существующих case "tool_error":
case "tool_retry": {
const toolCall = host.activeToolCalls.find(t => t.name === meta.tool);
if (toolCall) {
toolCall.status = "retrying";
toolCall.retryCount = meta.attempt;
toolCall.maxRetries = meta.max_attempts;
}
break;
}
case "tool_timeout": {
const toolCall = host.activeToolCalls.find(t => t.name === meta.tool);
if (toolCall) {
toolCall.status = "timeout";
toolCall.timeoutSeconds = meta.timeout_seconds;
}
break;
}
// Расширить существующий case "confirm_required":
case "confirm_required": {
host.pendingToolName = meta.tool_name;
host.pendingToolArgs = meta.tool_args || {};
host.pendingConfirmationRisk = meta.risk === "read" ? "read" : "write";
host.pendingRiskLevel = meta.risk_level;
host.confirmationMessage = meta.prompt || null;
host.streamingState = "awaiting_confirmation";
// NEW fields:
if (meta.dangerous) {
host.startDangerousCountdown?.();
}
if (!meta.permission_granted) {
host.permissionAlternatives = meta.alternatives;
host.pendingRiskLevel = "permission_denied";
}
break;
}
```
### 3b. EXTEND `handleResumeResponse` — cancelled tool card:
```typescript
// В обработчике deny/confirm_resolved с result "denied":
// Добавить ToolCall в activeToolCalls со статусом "cancelled"
// чтобы карточка «Отменено» появилась в чате
if (meta.type === "confirm_resolved" && meta.result === "denied") {
const toolName = host.pendingToolName || "unknown_action";
host.activeToolCalls = [...host.activeToolCalls, {
id: `cancelled-${Date.now()}`,
name: toolName,
status: "cancelled",
input: host.pendingToolArgs as Record<string, unknown> | undefined,
}];
host.pendingToolName = null;
host.pendingToolArgs = {};
}
```
---
## File 4: `frontend/src/routes/agent/+page.svelte`
### 4a. ADD onMount context init:
```svelte
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
// ... existing imports ...
onMount(() => {
// Read context from URL params
const params = new URLSearchParams($page.url.search);
model.setUIContextFromParams(params);
model.checkLlmStatus();
});
</script>
```
### 4b. ADD EnvSelector integration:
```svelte
<!-- Если EnvSelector присутствует на странице агента: -->
<EnvSelector
selectedEnvId={model._activeEnvId}
onEnvChange={(envId) => model.updateActiveEnv(envId)}
/>
```
---
## File 5: `frontend/src/lib/components/agent/AgentChat.svelte`
### 5a. ADD prod background binding (корневой div):
```svelte
<div
class="h-full flex flex-col min-h-0 {model.showProdWarning ? 'bg-warning-light' : 'bg-surface-page'}"
>
```
### 5b. ADD prod banner (перед header):
```svelte
{#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}
```
### 5c. UPDATE process steps rendering — первый шаг использует contextPillLabel:
```svelte
<!-- Вместо хардкода "Контекст: ss-dev" использовать model.contextPillLabel -->
{#each model.processSteps as step}
<span class="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium {processStepClasses(step.state)}">
{step.label}
</span>
{/each}
```
---
## File 6: `frontend/src/lib/components/assistant/ConfirmationCard.svelte`
### 6a. ADD env badge to header:
```svelte
{#if meta.env_context}
<span class="rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold
{meta.env_context === 'prod' ? 'bg-destructive-light text-destructive' : ''}
{meta.env_context === 'staging' ? 'bg-warning-light text-warning' : ''}
{meta.env_context === 'dev' ? 'bg-info-light text-info' : ''}
">
{meta.env_context === 'prod' ? '👑 PRODUCTION' : meta.env_context}
</span>
{/if}
```
### 6b. ADD dangerous countdown logic:
```svelte
<script lang="ts">
// ... existing ...
let dangerousTimer = $state(10);
let timerInterval: ReturnType<typeof setInterval> | null = null;
$effect(() => {
if (model.streamingState === "awaiting_confirmation" && model.pendingRiskLevel === "dangerous") {
dangerousTimer = 10;
timerInterval = setInterval(() => {
dangerousTimer--;
if (dangerousTimer <= 0) {
if (timerInterval) clearInterval(timerInterval);
timerInterval = null;
}
}, 1000);
}
return () => {
if (timerInterval) clearInterval(timerInterval);
};
});
</script>
<!-- Кнопка подтверждения для dangerous: -->
<Button
variant="destructive"
size="sm"
isLoading={loading === "confirming"}
disabled={loading !== "idle" || dangerousTimer > 0}
onclick={handleConfirm}
>
{dangerousTimer > 0
? `💀 Удалить (${dangerousTimer})`
: loading === "confirming"
? "Подтверждение…"
: "💀 Удалить"}
</Button>
```
### 6c. ADD permission_denied state (alternatives list):
```svelte
{#if !meta.permission_granted}
<div class="mb-3 rounded-lg border border-border bg-surface-page px-3.5 py-2.5">
<p class="text-sm font-medium text-text mb-1">
🔒 Недостаточно прав
</p>
<p class="text-xs text-text-muted mb-2">
Требуется роль: <code class="rounded bg-surface-muted px-1 py-0.5 font-mono text-destructive">{meta.required_role}</code>
</p>
{#if meta.alternatives?.length}
<p class="text-xs font-medium text-text mb-1">💡 Альтернативы:</p>
<ul class="space-y-1">
{#each meta.alternatives as alt}
<li class="text-xs text-text-muted">{alt.prompt}</li>
{/each}
</ul>
{/if}
</div>
<Button variant="ghost" size="sm" onclick={handleClosePermissionDenied}>Закрыть</Button>
{/if}
```
---
## File 7: `frontend/src/lib/components/assistant/ToolCallCard.svelte`
### 7a. ADD new states:
```svelte
<script lang="ts">
export let onRetry: (() => void) | undefined = undefined;
export let onCancelTimeout: (() => void) | undefined = undefined;
</script>
{#if tool.status === "retrying"}
<div class="flex items-center gap-2">
<span class="animate-spin"></span>
<span class="text-xs text-text-muted">
Повторная попытка... ({tool.retryCount}/{tool.maxRetries})
</span>
</div>
{:else if tool.status === "timeout"}
<div class="flex items-center gap-2">
<span>⏱️</span>
<span class="text-xs text-warning">Превышено время ожидания ({tool.timeoutSeconds}с)</span>
</div>
{#if onRetry}
<Button variant="secondary" size="xs" onclick={onRetry} class="mt-1">
🔄 Повторить
</Button>
{/if}
{:else if tool.status === "cancelled"}
<div class="flex items-center gap-2">
<span>🚫</span>
<span class="text-xs text-text-muted">Отменено пользователем</span>
</div>
{/if}
```
---
## File 8: `frontend/src/lib/stores/assistantChat.svelte.ts`
### 8a. ADD initialContext field:
```typescript
// Добавить в существующий объект assistantChatStore:
initialContext: UIContext | null = $state(null);
// Добавить action:
setInitialContext(ctx: UIContext | null) {
this.initialContext = ctx;
}
```
---
## File 9: `backend/src/agent/app.py`
### 9a. ADD uicontext parameter to agent_handler:
```python
# В сигнатуре agent_handler:
async def agent_handler(
message: dict,
history: list,
conversation_id: str | None = None,
uicontext_str: str | None = None, # NEW — JSON string from frontend
user_id: str | None = None,
user_jwt: str | None = None,
env_id: str | None = None,
):
# Parse uicontext:
uicontext = None
if uicontext_str:
try:
uicontext = json.loads(uicontext_str)
except json.JSONDecodeError:
pass
# Inject into runtime context:
if uicontext:
runtime_parts = _build_agent_context(env_id) # existing
runtime_parts = _inject_uicontext(runtime_parts, uicontext) # NEW
# Filter tools by objectType:
tools = get_all_tools()
if uicontext and uicontext.get("objectType"):
tools = _filter_tools_by_context(tools, uicontext["objectType"])
```
### 9b. NEW helper function:
```python
def _inject_uicontext(runtime_context: str, uicontext: dict) -> str:
"""Add user-facing context to the runtime context block."""
lines = [runtime_context]
lines.append("\n[USER CONTEXT]")
lines.append(f"User is currently on page: {uicontext.get('route', 'unknown')}")
if uicontext.get("objectType") and uicontext.get("objectId"):
lines.append(f"Active object: {uicontext['objectType']} id={uicontext['objectId']}")
if uicontext.get("objectName"):
lines.append(f"Object name: {uicontext['objectName']}")
if uicontext.get("envId"):
lines.append(f"Current environment: {uicontext['envId']}")
lines.append("[/USER CONTEXT]")
return "\n".join(lines)
```
---
## File 10: `backend/src/agent/_tool_filter.py` (NEW)
```python
# backend/src/agent/_tool_filter.py
# #region AgentChat.ToolFilter [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,filter,context]
# @defgroup AgentChat Context-aware tool filtering.
from typing import Any
# Mapping: objectType → set of relevant tool operations
_CONTEXT_TOOL_AFFINITY: dict[str, set[str]] = {
"dashboard": {
"search_dashboards", "get_health_summary", "deploy_dashboard",
"run_llm_validation", "run_llm_documentation", "execute_migration",
"create_branch", "commit_changes", "show_capabilities",
"get_task_status", "list_environments",
},
"dataset": {
"superset_explore_database", "superset_format_sql",
"superset_audit_permissions", "superset_execute_sql",
"superset_create_dataset", "search_dashboards",
"show_capabilities", "get_task_status", "list_environments",
},
"migration": {
"execute_migration", "search_dashboards", "get_health_summary",
"deploy_dashboard", "show_capabilities", "list_environments",
},
}
def filter_tools_by_context(tools: list[Any], object_type: str | None) -> list[Any]:
"""Filter tool list to context-relevant tools only."""
if not object_type or object_type not in _CONTEXT_TOOL_AFFINITY:
return tools # no filter — return all tools
allowed = _CONTEXT_TOOL_AFFINITY[object_type]
# Always include show_capabilities
allowed.add("show_capabilities")
return [t for t in tools if t.name in allowed]
def get_tool_affinity(object_type: str | None) -> list[dict[str, Any]]:
"""Return affinity list for debug panel (all tools, marked active/inactive)."""
# Used by debug endpoint
...
# #endregion AgentChat.ToolFilter
```
---
## Итого по изменениям
| Файл | Тип изменения | Строк (±) |
|------|:------------:|:---------:|
| `AgentChatTypes.ts` | Расширение типов | +35 |
| `AgentChatModel.svelte.ts` | Добавление атомов/derived/actions | +60 |
| `AgentChat.StreamProcessor.svelte.ts` | Новые handler'ы | +35 |
| `AgentChat.svelte` | Prod bg + banner + pill | +15 |
| `ConfirmationCard.svelte` | Новые состояния + countdown | +50 |
| `ToolCallCard.svelte` | Новые состояния + retry button | +25 |
| `assistantChat.svelte.ts` | Поле initialContext | +5 |
| `agent/+page.svelte` | onMount context init | +10 |
| `backend: app.py` | uicontext parsing | +25 |
| `backend: _tool_filter.py` | Новый файл | +40 |
| **Всего** | | **~300 строк** |
#endregion ModelChanges