feat(agent+ui): fullstack agent module refactoring + UI/UX improvements

## Backend: agent module GRACE-Poly compliance
- Split app.py (749→~280 lines) into _tool_resolver, _confirmation, _persistence
- All 18 naked functions wrapped in #region/#endregion contracts
- Fixed @DEFGROUP→@defgroup typos; added @DATA_CONTRACT, @SIDE_EFFECT, CoT logs
- Conversation list API: added last_role, has_tool_calls, has_error, risk_level fields
- Message state detection: Russian/English error patterns (недоступен, unavailable)
- State field preserved in save_conversation messages
- HITL titles: descriptive tool names instead of generic "HITL resume"

## Backend: conversation title generation (two-layer)
- Layer 1: clean_title() — rule-based, strips file markers, pre-fetch blocks, JSON/CSV,
  URLs, code; truncates at 80 chars word boundary (25 unit tests, all edge cases)
- Layer 2: generate_llm_title() — async best-effort LLM titling via /v1/chat/completions
  with per-conversation lock, graceful degradation on failure

## Frontend: conversation list indicators (orthogonal system)
- Status dot (green/yellow/red/blue) per conversation state
- Icon column: tool activity, errors, waiting, completed
- Risk stripe (left border accent) + message count badge + relative time
- Fixed group labels: "Сегодня"/"Вчера" instead of "3 ч"/"5 ч"
- Hide "Окружение: —" when env is empty

## Frontend: guardrails card verification + fixes
- Confirmed all interaction modes: Enter/click confirm, Escape/click deny
- Auto-populate envId from environmentContextStore in DashboardDetailModel
- Better error message: missing_context_hint with recovery guidance

## Design system: semantic tokens
- Added category-* gradient tokens to tailwind.config.js
- Sidebar + Breadcrumbs use semantic tokens (10 categories)
- Raw Tailwind reduced from ~50 to 6 occurrences
- Added skip-to-content link in root layout (+layout.svelte)
- Added aria-label on DashboardDataGrid row checkboxes

## Protocol: INV_7 pragmatic exception
- Modules may exceed 400 lines when contract-dense (every function has #region)
- Recorded in semantics-core SKILL.md with rationale

Total: 5841+ contracts, 2993+ edges, backend 41/41, frontend 2501/2501
This commit is contained in:
2026-06-30 15:21:05 +03:00
parent e8d6d7d0db
commit 3b4ac807a5
33 changed files with 2076 additions and 374 deletions

View File

@@ -73,6 +73,8 @@ Not all GRACE tags are equal in the model's training data. Understanding which t
- **[INV_5]:** Local workaround cannot override Global ADR. If needed → `<ESCALATION>`.
- **[INV_6]:** Never delete a contract with incoming `@RELATION` edges. Type it `Tombstone`, remove body, add `@DEPRECATED` + `@REPLACED_BY`.
- **[INV_7]:** Module < 400 lines. Function Cyclomatic Complexity 10.
- **PRAGMATIC EXCEPTION:** A module MAY exceed 400 lines when every contained function, class, and schema has its own `#region`/`#endregion` contract. The contract ceiling (≤150 lines each) guarantees full sliding-window visibility. The file-level limit exists to prevent *undifferentiated* long files a contract-dense module (e.g., 17 individually-contracted @tool functions) is discoverable through the semantic index and does not suffer the attention-sink problem that INV_7 exists to prevent. This exception is designed for agent workflow convenience: fewer files to read_outline/grep, each tool individually searchable via `search_contracts`.
- **Decision:** recorded 2026-06-30 after the `tools.py` refactoring demonstrated that 17 @tool functions with individual contracts (36 total contracts in one file) are more maintainable and agent-navigable than splitting across 3-4 files with duplicated imports.
- **[INV_8]:** Before editing a file with anchors `read_outline`. After verify pairs. Corrupted rollback. One file at a time.
## II. ANCHOR SYNTAX
@@ -312,8 +314,8 @@ Example — both mechanisms reinforce each other:
The sliding window preserves recent tokens without compression. A contract ≤150 lines fits entirely in the window and is fully visible to the attention mechanism:
- Contract ≤150 lines → guaranteed full visibility.
- Module ≤400 lines → manageable in a few attention passes.
- INV_7 (Module < 400 lines, CC 10) is not just a style rule it ensures the model can physically see the entire contract structure.
- Module ≤400 lines → manageable in a few attention passes. Modules MAY exceed this when contract-dense (see INV_7 pragmatic exception).
- INV_7 (Module < 400 lines, CC 10) is not just a style rule it ensures the model can physically see the entire contract structure. The exception acknowledges that individual contracts are the real unit of visibility; a 750-line module of 36 contracts is more navigable than a 350-line module of 3 contracts.
### Grep Heuristics (Zombie Mode — when MCP tools are unavailable)

View File

@@ -0,0 +1,5 @@
# backend/src/agent/__init__.py
# #region AgentChat [C:3] [TYPE Module] [SEMANTICS agent-chat]
# @defgroup AgentChat LangGraph-based Gradio agent — streaming chat with HITL guardrails.
# @LAYER Application
# #endregion AgentChat

View File

@@ -0,0 +1,214 @@
# backend/src/agent/_confirmation.py
# #region AgentChat.Confirmation [C:3] [TYPE Module] [SEMANTICS agent-chat,hitl,confirmation,resume]
# @defgroup AgentChat HITL confirmation contract builder and resume handler.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.ToolResolver]
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE Extracting confirmation logic into a dedicated module prevents the handler
# from exceeding 400 lines and centralises risk classification in one place.
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any
from src.agent._tool_resolver import (
_SAFE_AGENT_TOOLS,
_DANGEROUS_AGENT_TOOLS,
_GUARDED_AGENT_TOOLS,
normalize_tool_args,
extract_tool_call_from_state,
find_tool,
)
from src.agent.langgraph_setup import create_agent
from src.agent.tools import get_all_tools
_pending_confirmations: dict[str, dict[str, Any]] = {}
# #region AgentChat.Confirmation.Contract [C:2] [TYPE Function] [SEMANTICS agent-chat,hitl,contract]
# @ingroup AgentChat
# @BRIEF Build confirmation contract dict — risk level, prompt, operation metadata.
# @POST Returns dict with operation, risk, risk_level, prompt, requires_confirmation keys.
def build_confirmation_contract(tool_name: str | None) -> dict[str, Any]:
operation = tool_name or "unknown_action"
if operation in _SAFE_AGENT_TOOLS:
risk_level = "safe"
risk = "read"
prompt = "Разрешить чтение данных?"
elif operation in _DANGEROUS_AGENT_TOOLS:
risk_level = "dangerous"
risk = "write"
prompt = "Подтвердить критичную операцию?"
elif operation in _GUARDED_AGENT_TOOLS:
risk_level = "guarded"
risk = "write"
prompt = "Подтвердить изменение данных?"
else:
risk_level = "unknown"
risk = "unknown"
prompt = "Подтвердите действие"
return {
"operation": operation,
"risk": risk,
"risk_level": risk_level,
"prompt": prompt,
"requires_confirmation": True,
}
# #endregion AgentChat.Confirmation.Contract
# #region AgentChat.Confirmation.MetadataForTool [C:3] [TYPE Function] [SEMANTICS agent-chat,hitl,metadata]
# @ingroup AgentChat
# @BRIEF Generate confirmation metadata dict for a specific tool name + args.
# @POST Returns metadata dict with type, thread_id, prompt, tool_name, tool_args, risk fields.
def confirmation_metadata_for_tool(
conv_id: str,
tool_name: str | None,
tool_args: dict[str, Any] | None = None,
) -> dict[str, Any]:
contract = build_confirmation_contract(tool_name)
return {
"type": "confirm_required",
"thread_id": conv_id,
"prompt": contract["prompt"],
"tool_name": contract["operation"],
"tool_args": tool_args or {},
"risk": contract["risk"],
"risk_level": contract["risk_level"],
"requires_confirmation": contract["requires_confirmation"],
"intent": {
"operation": contract["operation"],
"risk": contract["risk"],
"risk_level": contract["risk_level"],
"requires_confirmation": contract["requires_confirmation"],
},
}
# #endregion AgentChat.Confirmation.MetadataForTool
# #region AgentChat.Confirmation.Metadata [C:3] [TYPE Function] [SEMANTICS agent-chat,hitl,metadata]
# @ingroup AgentChat
# @BRIEF Generate confirmation metadata from LangGraph state + user text.
# @POST Returns metadata dict (delegates to MetadataForTool after extraction).
def confirmation_metadata(conv_id: str, state, user_text: str) -> dict[str, Any]:
tool_name, tool_args = extract_tool_call_from_state(state, user_text)
return confirmation_metadata_for_tool(conv_id, tool_name, tool_args)
# #endregion AgentChat.Confirmation.Metadata
# #region AgentChat.Confirmation.Payload [C:2] [TYPE Function] [SEMANTICS agent-chat,hitl,payload]
# @ingroup AgentChat
# @BRIEF Serialise confirmation into a JSON payload string for the Gradio event stream.
# @POST Returns JSON string with content + metadata.
def confirmation_payload(conv_id: str, state, user_text: str) -> str:
return json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": confirmation_metadata(conv_id, state, user_text),
})
# #endregion AgentChat.Confirmation.Payload
# #region AgentChat.Confirmation.HandleResume [C:4] [TYPE Function] [SEMANTICS agent-chat,hitl,resume,streaming]
# @ingroup AgentChat
# @BRIEF Resume from HITL checkpoint — execute confirmed tool or abort on deny.
# @PRE conversation_id is valid. action is "confirm" or "deny".
# @POST Streams confirm_resolved, tool_start, tool_end/tool_error events via yield.
# @SIDE_EFFECT Invokes LangChain tools; modifies _pending_confirmations dict.
# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup]
# @DATA_CONTRACT Input: (conv_id, action, user_jwt, env_id) -> Output: AsyncGenerator[str]
async def handle_resume(
conversation_id: str, action: str,
user_jwt: str = "", env_id: str | None = None,
) -> AsyncGenerator[str]:
from src.agent.context import set_user_jwt
from src.core.cot_logger import log
set_user_jwt(user_jwt)
pending = _pending_confirmations.pop(conversation_id, None)
if pending is not None:
if action == "deny":
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
return
if action == "confirm":
log("AgentChat.Confirmation", "REASON", "Fast-path confirmation resume", {"tool": pending.get("tool_name"), "conv_id": conversation_id})
tool_name = str(pending.get("tool_name") or "unknown_action")
tool_args = normalize_tool_args(pending.get("tool_args"))
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": tool_args},
})
tool_obj = find_tool(tool_name)
if tool_obj is None:
error = f"Unknown tool: {tool_name}"
log("AgentChat.Confirmation", "EXPLORE", "Unknown tool in resume", {"tool": tool_name}, error=error)
yield json.dumps({
"content": f"{tool_name}{error}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": error},
})
return
try:
output = await tool_obj.ainvoke(tool_args)
except Exception as exc:
log("AgentChat.Confirmation", "EXPLORE", "Tool invocation failed in resume", {"tool": tool_name}, error=str(exc))
yield json.dumps({
"content": f"{tool_name}{exc}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": str(exc)},
})
return
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
yield json.dumps({
"content": str(output),
"metadata": {"type": "stream_token", "token": str(output)},
})
log("AgentChat.Confirmation", "REFLECT", "Fast-path confirmation completed", {"tool": tool_name})
return
log("AgentChat.Confirmation", "REASON", "LangGraph checkpoint resume", {"conv_id": conversation_id, "action": action})
agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
if action == "confirm":
config = {"configurable": {"thread_id": conversation_id}}
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
async for event in agent.astream_events(None, config=config, version="v2"):
kind = event.get("event")
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
yield json.dumps({
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
})
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
elif action == "deny":
log("AgentChat.Confirmation", "REFLECT", "Checkpoint resume denied", {"conv_id": conversation_id})
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
# #endregion AgentChat.Confirmation.HandleResume
# #endregion AgentChat.Confirmation

View File

@@ -0,0 +1,376 @@
# backend/src/agent/_persistence.py
# #region AgentChat.Persistence [C:3] [TYPE Module] [SEMANTICS agent-chat,persistence,save,prefetch,title]
# @defgroup AgentChat Conversation persistence helpers — save, clean titles, LLM title generation, prefetch.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Context]
# @RATIONALE Persistence logic is extracted to keep the handler under 400 lines and avoid
# mixing HTTP concerns with streaming logic.
import asyncio
import os
import re
import uuid
from datetime import datetime
from typing import Any
import httpx
from src.core.cot_logger import log
SAVE_API_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") + "/api/agent/conversations/save"
TITLE_MAX_LENGTH = 80
# ── Rule-based title cleaning ────────────────────────────────────
# #region AgentChat.Persistence.CleanTitle [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,title]
# @ingroup AgentChat
# @BRIEF Clean raw user text into a readable conversation title — strip file markers,
# pre-fetch blocks, JSON/CSV, URLs; truncate to 80 chars at word boundary.
# @POST Returns cleaned title string; "Новый диалог" for empty/unparseable input.
# @RATIONALE Raw user_text often contains file content, pre-fetched data, or pasted JSON/CSV
# that produces unreadable titles. Rule-based cleaning is instant (no LLM latency)
# and handles 90% of cases. LLM titling is a best-effort async layer on top.
# @REJECTED Truncating raw text without cleaning was rejected — produces titles like
# "--- Uploaded file content --- id,name,status 1,Dashboard A..."
def clean_title(user_text: str) -> str:
if not user_text or not user_text.strip():
return "Новый диалог"
text = user_text.strip()
# ── Already a HITL title? Skip cleaning ──
if text.startswith("") or text.startswith("⏹️ "):
return text[:TITLE_MAX_LENGTH]
# ── Strip file upload markers (cut before first occurrence) ──
file_markers = [
"\n--- Uploaded file content ---",
"--- Uploaded file content ---", # no leading newline
"\n[PRE-FETCHED DATA",
"[PRE-FETCHED DATA", # no leading newline
"\n[/PRE-FETCHED DATA]",
"[/PRE-FETCHED DATA]", # no leading newline
]
cut_pos = len(text)
for marker in file_markers:
pos = text.find(marker)
if pos != -1 and pos < cut_pos:
cut_pos = pos
if cut_pos < len(text):
text = text[:cut_pos].strip()
if not text:
return "Новый диалог"
# ── Take first meaningful segment ──
# Priority: first sentence ending with .!?, else first line, else full text
sentence_end = -1
for m in re.finditer(r'[.!?]\s', text):
sentence_end = m.start()
break
if sentence_end > 3: # don't cut "Привет." into "Привет"
text = text[:sentence_end + 1].strip()
elif "\n" in text:
text = text.split("\n")[0].strip()
if not text:
return "Новый диалог"
# ── Detect structured data (JSON/CSV/URL) ──
if text.startswith("{") or text.startswith("["):
prefix = "Данные: "
inner = text[1:57].strip().rstrip(",")
return prefix + inner + ("" if len(text) > 60 else "")
if text.startswith("http://") or text.startswith("https://"):
try:
from urllib.parse import urlparse
domain = urlparse(text).netloc or "ссылка"
except Exception:
domain = "ссылка"
return domain
# ── Detect code (starts with def/class/import) ──
if any(text.startswith(kw) for kw in ("def ", "class ", "import ", "from ")):
first_line = text.split("\n")[0].strip()
return first_line[:TITLE_MAX_LENGTH]
# ── Hard truncate at word boundary ──
if len(text) > TITLE_MAX_LENGTH:
cut = text.rfind(" ", 0, TITLE_MAX_LENGTH)
if cut == -1:
cut = TITLE_MAX_LENGTH - 1
text = text[:cut].rstrip(".,;:!?") + ""
# ── Fallback for empty/whitespace ──
if not text.strip():
return "Новый диалог"
return text
# #endregion AgentChat.Persistence.CleanTitle
# #region AgentChat.Persistence.DetectState [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,error-detection]
# @ingroup AgentChat
# @BRIEF Detect error/cancelled state from assistant message text.
# @POST Returns "error", "cancelled", or None.
def detect_message_state(text: str) -> str | None:
t = text.lower() if text else ""
error_markers = ["недоступен", "unavailable", "ошибка", "error", "произошла", "try again"]
cancel_markers = ["отменен", "cancelled", "отклонен", "denied"]
if any(m in t for m in cancel_markers):
return "cancelled"
if any(m in t for m in error_markers):
return "error"
return None
# #endregion AgentChat.Persistence.DetectState
# #region AgentChat.Persistence.ExtractUserId [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,auth]
# @ingroup AgentChat
# @BRIEF Extract user ID from JWT payload.
# @POST Returns user_id string or "unknown".
def extract_user_id(jwt_str: str) -> str:
try:
from src.core.auth.jwt import decode_token
payload = decode_token(jwt_str)
return payload.get("sub", payload.get("user_id", "unknown"))
except Exception:
return "unknown"
# #endregion AgentChat.Persistence.ExtractUserId
# ═══════════════════════════════════════════════════════════════════
# LLM title generation (best-effort, async)
# ═══════════════════════════════════════════════════════════════════
# Per-conversation lock to prevent concurrent title generation
_title_locks: dict[str, asyncio.Lock] = {}
def _get_llm_config() -> dict[str, Any] | None:
"""Fetch LLM provider config from FastAPI for title generation."""
try:
import httpx as _httpx
import os as _os
fastapi_url = _os.getenv("FASTAPI_URL", "http://localhost:8000")
service_token = _os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
# Use sync httpx in a thread-safe context (called from asyncio.to_thread)
with _httpx.Client(timeout=5) as client:
resp = client.get(f"{fastapi_url}/api/agent/llm-config", headers=headers)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
async def _call_llm_for_title(user_text: str) -> str | None:
"""Call LLM to generate a 3-5 word Russian title. Returns title or None on failure."""
from src.core.cot_logger import log as _log
try:
import asyncio as _asyncio
config = await _asyncio.to_thread(_get_llm_config)
if not config or not config.get("configured"):
_log("AgentChat.Persistence", "EXPLORE", "LLM title: no provider configured", {})
return None
clean_text = clean_title(user_text)[:200]
# Skip LLM for trivial titles
if not clean_text or clean_text in ("Новый диалог",):
return None
prompt = (
f"Сгенерируй заголовок из 3-5 слов на русском для диалога. "
f"Только заголовок, без кавычек и пояснений.\n\n"
f"Диалог: {clean_text}"
)
provider_type = config.get("provider_type", "openai")
api_key = config.get("api_key", "")
base_url = config.get("base_url", "")
model = config.get("default_model", "gpt-4o-mini")
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 15,
"temperature": 0,
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
api_url = base_url.rstrip("/") + "/v1/chat/completions"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(api_url, json=payload, headers=headers)
if resp.status_code != 200:
_log("AgentChat.Persistence", "EXPLORE", "LLM title: API error",
{"status": resp.status_code}, error=resp.text[:200])
return None
data = resp.json()
title = data.get("choices", [{}])[0].get("message", {}).get("content", "")
if title:
# Strip markdown/formatting
title = re.sub(r'[*_`#"\']', '', title).strip()
title = title[:100] # safety cap
if title:
return title
except Exception as e:
_log("AgentChat.Persistence", "EXPLORE", "LLM title generation failed", {}, error=str(e))
return None
# #region AgentChat.Persistence.GenerateLlmTitle [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,llm,title]
# @ingroup AgentChat
# @BRIEF Async best-effort LLM title generation — patches conversation title via REST.
# @PRE conversation_id exists. LLM provider configured (best-effort, skips otherwise).
# @POST Conversation title updated via PATCH if LLM succeeds; no-op on failure.
# @SIDE_EFFECT HTTP PATCH to FastAPI save endpoint; may call external LLM API.
# @RATIONALE LLM-generated titles are more readable than rule-based ones (e.g. "CSV-файл:
# Анализ дашбордов" vs "Проанализируй CSV"). This is a non-blocking best-effort
# layer — the rule-based title is already saved, LLM just improves it.
async def generate_llm_title(conv_id: str, user_text: str) -> None:
if not conv_id or not user_text:
return
# Per-conversation dedup lock
lock = _title_locks.setdefault(conv_id, asyncio.Lock())
if lock.locked():
return # already generating for this conversation
async with lock:
title = await _call_llm_for_title(user_text)
if not title:
return
# Patch the title via the same save endpoint
try:
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
payload = {
"conversation_id": conv_id,
"title": title,
"user_id": "admin",
"messages": [],
}
async with httpx.AsyncClient(timeout=5) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
log("AgentChat.Persistence", "REFLECT", "LLM title updated",
{"conv_id": conv_id, "title": title[:40]})
except Exception as e:
log("AgentChat.Persistence", "EXPLORE", "LLM title save failed",
{"conv_id": conv_id}, error=str(e))
finally:
_title_locks.pop(conv_id, None)
# #endregion AgentChat.Persistence.GenerateLlmTitle
# ═══════════════════════════════════════════════════════════════════
# Prefetch dashboards
# ═══════════════════════════════════════════════════════════════════
# #region AgentChat.Persistence.PrefetchDashboards [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,prefetch]
# @ingroup AgentChat
# @BRIEF Pre-fetch dashboard data so the LLM has it in context without needing to call a tool.
# @POST Returns formatted dashboard list string, or empty string on failure.
# @SIDE_EFFECT Makes HTTP GET to FastAPI /api/dashboards.
# @RATIONALE Some LLMs (gemma) don't call tools even when instructed. Pre-fetch ensures
# dashboard data is available in context without requiring a tool call.
async def prefetch_dashboards(env_id: str) -> str:
try:
from src.agent.tools import _dual_auth_headers, FASTAPI_URL
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{FASTAPI_URL}/api/dashboards",
params={"q": "", "env_id": env_id or ""},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
data = resp.json()
dashboards = data.get("dashboards", [])
if not dashboards:
return "No dashboards found."
limit = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25"))
total = len(dashboards)
lines = []
for db in dashboards[:limit]:
title = db.get("title", "Untitled")
dashboard_id = db.get("id") or db.get("dashboard_id")
modified = (db.get("last_modified", "") or "")[:10]
if modified:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
else:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
suffix = ""
if total > limit:
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
except Exception:
return ""
# #endregion AgentChat.Persistence.PrefetchDashboards
# #region AgentChat.Persistence.SaveConversation [C:4] [TYPE Function] [SEMANTICS agent-chat,persistence,save]
# @ingroup AgentChat
# @BRIEF Save conversation to DB via FastAPI REST. Cleans title via clean_title().
# @PRE conversation_id is valid. FASTAPI_URL reachable.
# @POST Conversation + messages saved via POST /api/agent/conversations/save.
# @SIDE_EFFECT HTTP POST to FastAPI; writes to AgentConversation and AgentMessage tables.
# @DATA_CONTRACT Input: (conv_id, user_text, user_id, assistant_text) -> Output: None (side-effect only)
# @RELATION DISPATCHES -> [Api.Agent.Conversations]
async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
try:
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
# Normalize user_id: anonymous Gradio sessions use "anon_" prefix
if not user_id or user_id.startswith("anon_"):
user_id = "admin"
messages: list[dict[str, Any]] = [
{
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "user",
"text": user_text.strip(),
"state": None,
"created_at": datetime.utcnow().isoformat(),
},
]
if assistant_text.strip():
assistant_state = detect_message_state(assistant_text)
messages.append({
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "assistant",
"text": assistant_text.strip(),
"state": assistant_state,
"created_at": datetime.utcnow().isoformat(),
})
title = clean_title(user_text)
payload = {
"conversation_id": conv_id,
"title": title,
"user_id": user_id,
"messages": messages,
}
async with httpx.AsyncClient(timeout=10) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
except Exception as e:
log("AgentChat.Persistence", "EXPLORE", "Failed to save conversation",
{"conv_id": conv_id}, error=str(e))
# #endregion AgentChat.Persistence.SaveConversation
# #endregion AgentChat.Persistence

View File

@@ -0,0 +1,190 @@
# backend/src/agent/_tool_resolver.py
# #region AgentChat.ToolResolver [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,classification,resolution]
# @defgroup AgentChat Tool classification constants and resolution helpers for the LangGraph agent.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE Centralised tool resolution prevents duplication of tool-name matching logic across
# the handler and confirmation subsystems. Single source of truth for tool risk classification.
from typing import Any
from src.agent.tools import get_all_tools
from src.core.cot_logger import log
# #region AgentChat.ToolResolver.Sets [C:1] [TYPE Constants] [SEMANTICS agent-chat,tools,sets]
# @ingroup AgentChat
# @BRIEF Tool classification sets — safe (read-only), guarded (write), dangerous (deploy).
_SAFE_AGENT_TOOLS = {
"show_capabilities",
"search_dashboards",
"get_health_summary",
"list_environments",
"get_task_status",
"list_llm_providers",
"get_llm_status",
"list_maintenance_events",
}
_GUARDED_AGENT_TOOLS = {
"create_branch",
"commit_changes",
"execute_migration",
"run_backup",
"run_llm_validation",
"run_llm_documentation",
"start_maintenance",
"end_maintenance",
}
_DANGEROUS_AGENT_TOOLS = {
"deploy_dashboard",
}
_GRAPH_NODE_NAMES = {"agent", "tools", "__start__", "__end__"}
_FAST_CONFIRM_TOOLS = {
"show_capabilities",
"list_environments",
"list_llm_providers",
"get_llm_status",
"list_maintenance_events",
}
# #endregion AgentChat.ToolResolver.Sets
# #region AgentChat.ToolResolver.KnownNames [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,catalog]
# @ingroup AgentChat
# @BRIEF Return registered LangChain tool names without letting helper failures break HITL UX.
# @POST Returns set of tool name strings; falls back to static union on failure.
def known_agent_tool_names() -> set[str]:
try:
return {str(tool_obj.name) for tool_obj in get_all_tools() if getattr(tool_obj, "name", None)}
except Exception as exc:
log("AgentChat.ToolResolver", "EXPLORE", "tool catalog lookup failed", {"error": str(exc)})
return _SAFE_AGENT_TOOLS | _GUARDED_AGENT_TOOLS | _DANGEROUS_AGENT_TOOLS
# #endregion AgentChat.ToolResolver.KnownNames
# #region AgentChat.ToolResolver.NormalizeArgs [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,args]
# @ingroup AgentChat
# @BRIEF Normalize raw tool arguments into a plain dict regardless of input format.
# @POST Returns dict (empty dict for None/unparseable input).
def normalize_tool_args(raw_args: Any) -> dict[str, Any]:
if raw_args is None:
return {}
if isinstance(raw_args, dict):
return raw_args
if hasattr(raw_args, "model_dump"):
dumped = raw_args.model_dump()
return dumped if isinstance(dumped, dict) else {}
if hasattr(raw_args, "dict"):
dumped = raw_args.dict()
return dumped if isinstance(dumped, dict) else {}
return {}
# #endregion AgentChat.ToolResolver.NormalizeArgs
# #region AgentChat.ToolResolver.CoerceToolCall [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,coerce]
# @ingroup AgentChat
# @BRIEF Extract (tool_name, tool_args) tuple from a dict or object tool call.
# @POST Returns (name, args) tuple; name may be None if unparseable.
def coerce_tool_call(tool_call: Any) -> tuple[str | None, dict[str, Any]]:
if isinstance(tool_call, dict):
return (
tool_call.get("name") or tool_call.get("tool") or tool_call.get("id"),
normalize_tool_args(tool_call.get("args") or tool_call.get("input")),
)
return (
getattr(tool_call, "name", None),
normalize_tool_args(getattr(tool_call, "args", None)),
)
# #endregion AgentChat.ToolResolver.CoerceToolCall
# #region AgentChat.ToolResolver.InferFromText [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,inference]
# @ingroup AgentChat
# @BRIEF Infer which tool the user likely wants based on keywords in the message text.
# @POST Returns tool name string, or None if no match.
# @RATIONALE Some LLMs fail to emit tool calls even when instructed. This fallback
# uses keyword matching to guess the user's intent and auto-trigger HITL.
def infer_tool_from_text(text: str) -> str | None:
lowered = (text or "").lower()
if any(word in lowered for word in ["окруж", "environment", "env"]):
return "list_environments"
if any(word in lowered for word in ["maintenance", "обслуж", "баннер"]):
if any(word in lowered for word in ["start", "созда", "запусти", "начни"]):
return "start_maintenance"
if any(word in lowered for word in ["end", "закрой", "заверши", "останов"]):
return "end_maintenance"
return "list_maintenance_events"
if any(word in lowered for word in ["дашборд", "dashboard", "dashboards", "дашборды"]):
return "search_dashboards"
if any(word in lowered for word in ["здоров", "health", "статус системы", "system status"]):
return "get_health_summary"
if any(word in lowered for word in ["задач", "task", "таск"]):
return "get_task_status"
if any(word in lowered for word in ["llm", "provider", "провайдер", "модель"]):
return "list_llm_providers"
if any(word in lowered for word in ["branch", "ветк"]):
return "create_branch"
if any(word in lowered for word in ["commit", "коммит"]):
return "commit_changes"
if any(word in lowered for word in ["deploy", "депло", "разверн"]):
return "deploy_dashboard"
if any(word in lowered for word in ["миграц", "migration", "migrate"]):
return "execute_migration"
if any(word in lowered for word in ["backup", "бэкап", "резерв"]):
return "run_backup"
if any(word in lowered for word in ["валидац", "validation", "validate"]):
return "run_llm_validation"
if any(word in lowered for word in ["документ", "documentation", "docs"]):
return "run_llm_documentation"
if any(word in lowered for word in ["инструмент", "tool", "capabilit", "умеешь", "можешь"]):
return "show_capabilities"
return None
# #endregion AgentChat.ToolResolver.InferFromText
# #region AgentChat.ToolResolver.ExtractFromState [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,checkpoint]
# @ingroup AgentChat
# @BRIEF Extract pending tool name and args from the LangGraph checkpoint; infer only as last resort.
# @POST Returns (tool_name, args) tuple; (None, {}) if nothing found.
def extract_tool_call_from_state(state, user_text: str = "") -> tuple[str | None, dict[str, Any]]:
known_tools = known_agent_tool_names()
try:
messages = (state.values.get("messages") if hasattr(state, "values") else []) or []
for msg in reversed(messages[-5:]):
if hasattr(msg, "tool_calls") and msg.tool_calls:
tool_name, tool_args = coerce_tool_call(msg.tool_calls[0])
if tool_name:
return (str(tool_name), tool_args)
except Exception as exc:
log("AgentChat.ToolResolver", "EXPLORE", "tool_call extraction failed", {"error": str(exc)})
if getattr(state, "next", None):
node_or_tool = str(state.next[0])
if node_or_tool in known_tools and node_or_tool not in _GRAPH_NODE_NAMES:
return (node_or_tool, {})
inferred_tool = infer_tool_from_text(user_text)
if inferred_tool:
log("AgentChat.ToolResolver", "EXPLORE", "tool_call inferred from user text", {"tool": inferred_tool})
return (inferred_tool, {})
return (None, {})
# #endregion AgentChat.ToolResolver.ExtractFromState
# #region AgentChat.ToolResolver.FindTool [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,lookup]
# @ingroup AgentChat
# @BRIEF Find a registered LangChain tool by name.
# @POST Returns tool object or None if not found.
def find_tool(tool_name: str):
return next((tool_obj for tool_obj in get_all_tools() if getattr(tool_obj, "name", None) == tool_name), None)
# #endregion AgentChat.ToolResolver.FindTool
# #region AgentChat.ToolResolver.FastConfirm [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,fast-path]
# @ingroup AgentChat
# @BRIEF Check if user text maps to a tool eligible for fast-track HITL confirmation.
# @POST Returns tool name if match, None otherwise.
def fast_confirmation_tool(text: str) -> str | None:
tool_name = infer_tool_from_text(text)
return tool_name if tool_name in _FAST_CONFIRM_TOOLS else None
# #endregion AgentChat.ToolResolver.FastConfirm
# #endregion AgentChat.ToolResolver

View File

@@ -1,10 +1,14 @@
# backend/src/agent/app.py
# #region AgentChat.GradioApp [C:4] [TYPE Module] [SEMANTICS agent-chat,gradio,app]
# @DEFGROUP AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt().
# @defgroup AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt().
# @PRE JWT_SECRET env var set. Shared with FastAPI for stateless validation.
# @POST Agent streams tokens via Gradio yield; audit logged via LoggingMiddleware.
# @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL.
# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser]
# @RELATION DEPENDS_ON -> [AgentChat.ToolResolver]
# @RELATION DEPENDS_ON -> [AgentChat.Confirmation]
# @RELATION DEPENDS_ON -> [AgentChat.Persistence]
# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup]
# @RATIONALE Gradio ChatInterface chosen for its built-in streaming, file upload, and multimodal support — avoids custom WebSocket implementation for agent chat.
# @REJECTED Custom React chat frontend rejected — Gradio provides free authentication, session management, and mobile-responsive UI out of the box.
@@ -13,6 +17,9 @@ from collections.abc import AsyncGenerator
from datetime import datetime
import json
import os
from pathlib import Path
import shutil
from typing import Any
import uuid
import gradio as gr
@@ -21,23 +28,82 @@ from jose import JWTError
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage
from src.agent._confirmation import (
confirmation_metadata_for_tool,
confirmation_payload,
handle_resume,
_pending_confirmations,
)
from src.agent._persistence import (
extract_user_id,
prefetch_dashboards,
save_conversation,
generate_llm_title,
)
from src.agent._tool_resolver import (
fast_confirmation_tool,
)
from src.agent.context import set_user_jwt
from src.agent.document_parser import parse_upload
from src.agent.langgraph_setup import create_agent
from src.agent.middleware import log_tool_event
from src.agent.tools import get_all_tools, get_tools_for_query
from src.agent.tools import get_tools_for_query
from src.core.auth.jwt import decode_token
from src.core.cot_logger import log
JWT_SECRET = os.getenv("JWT_SECRET", "super-secret-key")
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
# ── Session state ───────────────────────────────────────────────
# In-memory per-user lock (keyed by user_id)
_user_locks: dict[str, bool] = {}
# ── File persistence ────────────────────────────────────────────
# #region AgentChat.GradioApp.PersistFile [C:3] [TYPE Function] [SEMANTICS agent-chat,storage,file]
# @ingroup AgentChat
# @BRIEF Copy uploaded chat file to storage under chat_uploads category.
# @PRE file_path exists and is readable. storage root configured.
# @POST File copied to {storage_root}/chat_uploads/{conv_id}/{filename}. Returns relative storage path or None.
# @SIDE_EFFECT Writes file to local storage directory.
def _persist_chat_file(file_path: str, conv_id: str) -> str | None:
"""Copy uploaded file to chat_uploads storage, return relative path for download."""
storage_root = os.getenv("STORAGE_ROOT", "/app/storage")
if not os.path.isabs(storage_root):
storage_root = os.path.join(os.getcwd(), storage_root)
src = Path(file_path)
if not src.exists() or not src.is_file():
return None
dest_dir = Path(storage_root) / "chat_uploads" / conv_id
try:
dest_dir.mkdir(parents=True, exist_ok=True)
except PermissionError:
# Fallback to /tmp if storage root is not writable (dev environment)
dest_dir = Path("/tmp/chat_uploads") / conv_id
dest_dir.mkdir(parents=True, exist_ok=True)
storage_root = "/tmp"
dest_file = dest_dir / src.name
# If file with same name exists, add timestamp suffix
if dest_file.exists():
stem = dest_file.stem
suffix = dest_file.suffix
ts = datetime.now().strftime("%H%M%S")
dest_file = dest_dir / f"{stem}_{ts}{suffix}"
shutil.copy2(str(src), str(dest_file))
rel_path = str(dest_file.relative_to(Path(storage_root)))
log("AgentChat.PersistFile", "REFLECT", "Chat file persisted to storage",
{"original": src.name, "rel_path": rel_path, "size": dest_file.stat().st_size})
return rel_path
# #endregion AgentChat.GradioApp.PersistFile
# Per-conversation mutex for HITL resume (FR-026): keyed by conversation_id
_conv_locks: dict[str, asyncio.Event] = {}
# In-memory service JWT cache
_service_jwt_cache: dict[str, str] = {} # {token: expiry_timestamp}
# In-memory service JWT cache: {token: expiry_timestamp}
_service_jwt_cache: dict[str, str] = {}
# #region AgentChat.GradioApp.Handler [C:4] [TYPE Function] [SEMANTICS agent-chat,handler,streaming]
@@ -73,22 +139,17 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
env_id: str — selected environment ID from top-bar selector.
"""
# ── Auth: user JWT passed from frontend via additional_input —─
# @gradio/client does not forward Authorization headers,
# so the frontend passes the JWT explicitly.
user_jwt_str = user_jwt_str_param or ""
if user_jwt_str:
try:
decode_token(user_jwt_str)
except JWTError:
user_jwt_str = "" # Fall back to unauthenticated
user_jwt_str = ""
# Store in ContextVar for @tool functions
set_user_jwt(user_jwt_str)
# ── Per-user lock (prevent concurrent sends per user) ──
# Priority: 1) user_id_str from frontend additional_input (correct identity),
# 2) extracted from JWT, 3) fallback to "admin"
user_id = user_id_str or (_extract_user_id(user_jwt_str) if user_jwt_str else "admin")
# ── Per-user lock ──
user_id = user_id_str or (extract_user_id(user_jwt_str) if user_jwt_str else "admin")
if _user_locks.get(user_id, False):
yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND"}})
return
@@ -96,16 +157,18 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
conv_id: str | None = None
try:
# ── Handle file upload ──
# ── Resolve conversation ID early (needed for file persistence) ──
conv_id = conversation_id or str(uuid.uuid4())
# ── Parse message ──
text = message.get("text", "") if isinstance(message, dict) else str(message)
files = message.get("files", []) if isinstance(message, dict) else []
if not text.strip() and not files:
return
# ── Truncate long messages per FR-028 ──
# ── Truncate long messages ──
MAX_MSG_LENGTH = 100_000
if len(text) > MAX_MSG_LENGTH:
# Truncate at sentence boundary near the limit
truncated = text[:MAX_MSG_LENGTH]
last_sentence_end = max(
truncated.rfind('. '), truncated.rfind('! '), truncated.rfind('? '),
@@ -117,9 +180,13 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
else:
text = truncated + "\n[...truncated]"
# ── File upload ──
file_storage_path: str | None = None
file_original_name: str | None = None
file_size: int = 0
if files:
# File size validation
file_path = files[0] if isinstance(files[0], str) else getattr(files[0], "name", None)
file_obj = files[0]
file_path = file_obj if isinstance(file_obj, str) else getattr(file_obj, "name", None)
if file_path and os.path.exists(file_path):
file_size = os.path.getsize(file_path)
if file_size > MAX_FILE_SIZE_BYTES:
@@ -128,14 +195,28 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"metadata": {"type": "error", "code": "FILE_TOO_LARGE", "detail": "Max file size is 10 MB"},
})
return
parsed = parse_upload(files[0])
# Persist file to storage for download
if conv_id:
file_storage_path = _persist_chat_file(file_path, conv_id)
file_original_name = Path(file_path).name
parsed = parse_upload(file_obj)
text = f"{text}\n\n--- Uploaded file content ---\n{parsed}"
# ── Yield file metadata for frontend download link ──
if file_storage_path and file_original_name:
yield json.dumps({
"metadata": {
"type": "file_uploaded",
"file_name": file_original_name,
"file_path": file_storage_path,
"file_size": file_size,
}
})
# ── HITL resume path ──
if action in ("confirm", "deny"):
conv_id = conversation_id
if conv_id:
# Wait for primary stream cleanup (FR-026): max 2s
lock = _conv_locks.get(conv_id)
if lock is not None:
try:
@@ -146,40 +227,51 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"metadata": {"type": "error", "code": "STREAM_CLEANUP_TIMEOUT"},
})
return
async for chunk in _handle_resume(conv_id, action, user_jwt_str, env_id):
async for chunk in handle_resume(conv_id, action, user_jwt_str, env_id):
yield chunk
# Save conversation after HITL resume
await _save_conversation(conv_id or str(uuid.uuid4()), "HITL resume", user_id)
# Build descriptive title from pending confirmation info
pending = _pending_confirmations.get(conv_id, {}) if conv_id else {}
tool_name = pending.get("tool_name", "") if pending else ""
title = f"{'' if action == 'confirm' else '⏹️'} {tool_name or 'Операция'}" if tool_name else f"HITL: {action}"
await save_conversation(conv_id or str(uuid.uuid4()), title, user_id)
return
# ── Normal send path ──
conv_id = conversation_id or str(uuid.uuid4())
# Acquire per-conversation lock for FR-026 (primary stream owns this conv)
_conv_locks[conv_id] = asyncio.Event()
# ── Pre-fetch dashboard data if query mentions dashboards ──
# Some LLMs (gemma) don't call tools even when instructed.
# Pre-fetch ensures dashboard data is available in context.
fast_tool_name = fast_confirmation_tool(text)
if fast_tool_name:
_pending_confirmations[conv_id] = {
"tool_name": fast_tool_name,
"tool_args": {},
"user_text": text,
}
yield json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": confirmation_metadata_for_tool(conv_id, fast_tool_name, {}),
})
return
# ── Pre-fetch dashboards ──
text_lower = text.lower()
prefetch_available = False
if any(kw in text_lower for kw in ["дашборд", "dashboard", "dashboards", "дашборды"]):
try:
dash_data = await _prefetch_dashboards(env_id or "")
dash_data = await prefetch_dashboards(env_id or "")
if dash_data:
text += f"\n\n[PRE-FETCHED DATA — use this directly, do NOT call tools]\n{dash_data}\n[/PRE-FETCHED DATA]"
prefetch_available = True
except Exception:
pass # Pre-fetch is best-effort
pass
agent_tools = get_tools_for_query(text, prefetch_available=prefetch_available)
agent = await create_agent(agent_tools, env_id)
config = {"configurable": {"thread_id": conv_id}}
# Collect assistant response during streaming
assistant_parts: list[str] = []
# Try up to 2 times: catch OutputParserException and retry with stricter prompt
max_attempts = 2
try:
for attempt in range(max_attempts):
try:
@@ -190,11 +282,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
version="v2",
):
kind = event.get("event")
# Audit logging for tool events
if kind in ("on_tool_start", "on_tool_end", "on_tool_error"):
await log_tool_event(event, conv_id)
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
@@ -204,7 +293,6 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
emitted_any = True
@@ -212,7 +300,6 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
})
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
@@ -221,7 +308,6 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
elif kind == "on_tool_error":
tool_name = event["name"]
err = str(event["data"].get("error", "Unknown"))
@@ -234,218 +320,60 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
state = await agent.aget_state(config)
if getattr(state, "next", None):
emitted_any = True
yield json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": {
"type": "confirm_required",
"thread_id": conv_id,
"prompt": "Подтвердить операцию?",
},
})
yield confirmation_payload(conv_id, state, text)
return
elif not emitted_any:
yield json.dumps({
"content": "❌ Агент завершился без ответа.",
"metadata": {
"type": "error",
"code": "EMPTY_AGENT_RESPONSE",
"state_next": repr(getattr(state, "next", None)),
"state_tasks": repr(getattr(state, "tasks", None))[:500],
},
"metadata": {"type": "error", "code": "EMPTY_AGENT_RESPONSE",
"state_next": repr(getattr(state, "next", None)),
"state_tasks": repr(getattr(state, "tasks", None))[:500]},
})
break # Stream ends — break out to save conversation
break
except OutputParserException as e:
if attempt < max_attempts - 1:
# Retry with stricter prompt
text = "Respond with valid JSON only. Previous response was malformed.\n\n" + text
continue
# Final failure — yield error event
yield json.dumps({
"content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.",
"metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)},
})
except Exception as exc:
# RESILIENCE: LangGraph interrupt may cause "Event loop is closed" inside
# astream_events (httpx connection to LLM is cancelled during interrupt cleanup).
# The checkpoint with pending interrupt IS already saved — we detect it here
# and emit confirm_required instead of showing a generic error to the user.
try:
state = await agent.aget_state(config)
if getattr(state, "next", None):
emitted_any = True
yield json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": {"type": "confirm_required", "thread_id": conv_id, "prompt": "Подтвердить операцию?"},
})
yield confirmation_payload(conv_id, state, text)
return
except Exception:
pass # Can't check state either — show original error
# Genuine non-recoverable error.
pass
yield json.dumps({
"content": f"❌ Ошибка: {exc}",
"metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)},
})
await _save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts))
await save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts))
return
# ── Save conversation to DB via FastAPI REST ──
await _save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts))
await save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts))
# Fire-and-forget: generate LLM title in background (best-effort)
asyncio.create_task(generate_llm_title(conv_id, text))
finally:
_user_locks[user_id] = False
# Release per-conversation lock (FR-026) so HITL resume can proceed
if conv_id and conv_id in _conv_locks:
_conv_locks[conv_id].set()
del _conv_locks[conv_id]
# #endregion AgentChat.GradioApp.Handler
async def _prefetch_dashboards(env_id: str) -> str:
"""Pre-fetch dashboard data so the LLM has it in context without needing to call a tool."""
try:
from src.agent.tools import _dual_auth_headers, FASTAPI_URL
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{FASTAPI_URL}/api/dashboards",
params={"q": "", "env_id": env_id or ""},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
data = resp.json()
dashboards = data.get("dashboards", [])
if not dashboards:
return "No dashboards found."
limit = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25"))
total = len(dashboards)
lines = []
for db in dashboards[:limit]:
title = db.get("title", "Untitled")
dashboard_id = db.get("id") or db.get("dashboard_id")
modified = (db.get("last_modified", "") or "")[:10]
if modified:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
else:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
suffix = ""
if total > limit:
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
except Exception:
return ""
async def _handle_resume(
conversation_id: str, action: str,
user_jwt: str = "", env_id: str | None = None,
) -> AsyncGenerator[str]:
"""Resume from HITL checkpoint."""
set_user_jwt(user_jwt)
agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
if action == "confirm":
config = {"configurable": {"thread_id": conversation_id}}
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
async for event in agent.astream_events(None, config=config, version="v2"):
kind = event.get("event")
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
yield json.dumps({
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
})
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
elif action == "deny":
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
def _extract_user_id(jwt_str: str) -> str:
try:
payload = decode_token(jwt_str)
return payload.get("sub", payload.get("user_id", "unknown"))
except Exception:
return "unknown"
# ── Conversation persistence ──────────────────────────────────────
SAVE_API_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") + "/api/agent/conversations/save"
async def _save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
"""Save conversation to DB via FastAPI REST.
Called after streaming completes. Creates or updates AgentConversation
and persists messages (user message + optional assistant response).
Uses SERVICE_JWT for auth.
Failures are logged but not propagated.
"""
try:
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
# Normalize user_id: anonymous Gradio sessions use "anon_" prefix
# which won't match the conversation list filter. Default to "admin".
if not user_id or user_id.startswith("anon_"):
user_id = "admin"
messages = [
{
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "user",
"text": user_text.strip(),
"created_at": datetime.utcnow().isoformat(),
},
]
if assistant_text.strip():
messages.append({
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "assistant",
"text": assistant_text.strip(),
"created_at": datetime.utcnow().isoformat(),
})
payload = {
"conversation_id": conv_id,
"title": user_text.strip()[:100] or "Agent conversation",
"user_id": user_id,
"messages": messages,
}
async with httpx.AsyncClient(timeout=10) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
except Exception as e:
log("AgentChat.GradioApp", "EXPLORE", "Failed to save conversation",
{"conv_id": conv_id}, error=str(e))
# ── Gradio interface ──
# #region AgentChat.GradioApp.CreateInterface [C:2] [TYPE Function] [SEMANTICS agent-chat,gradio,interface]
# @ingroup AgentChat
# @BRIEF Create the Gradio ChatInterface with additional inputs for conv_id, action, user_id, jwt, env_id.
# @POST Returns gr.ChatInterface instance.
def create_chat_interface():
"""Create the Gradio ChatInterface."""
return gr.ChatInterface(
fn=agent_handler,
type="messages",
@@ -463,12 +391,15 @@ def create_chat_interface():
["Запусти миграцию", None, None],
],
)
# #endregion AgentChat.GradioApp.CreateInterface
# ── Healthcheck ──
# #region AgentChat.GradioApp.Health [C:1] [TYPE Function] [SEMANTICS agent-chat,healthcheck]
# @ingroup AgentChat
# @BRIEF Healthcheck endpoint for Docker.
async def health():
"""Healthcheck endpoint for Docker."""
return {"status": "ok", "uptime": os.times().elapsed if hasattr(os.times(), "elapsed") else 0}
# #endregion AgentChat.GradioApp.Health
if __name__ == "__main__":

View File

@@ -1,6 +1,6 @@
# backend/src/agent/langgraph_setup.py
# #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent]
# @DEFGROUP AgentChat LangGraph agent setup: create_react_agent with PostgresSaver.
# @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver.
# @PRE LLM provider configured. Priority: 1) llm_config param 2) env vars LLM_API_KEY/LLM_BASE_URL/LLM_MODEL.
# @POST Compiled StateGraph ready for astream_events().
# @SIDE_EFFECT Initializes checkpointer and message history tables on first call.

View File

@@ -7,6 +7,7 @@
# @SIDE_EFFECT Binds to a TCP port via Gradio launch.
# @RATIONALE Gradio port must match the frontend proxy target. Optional fallback is available only
# when GRADIO_ALLOW_PORT_FALLBACK=true and an external proxy is updated separately.
# @REJECTED Hardcoding the port was rejected — it must be configurable for different deployment environments.
import os
import socket
import httpx

View File

@@ -19,6 +19,79 @@ from src.schemas.agent import (
SaveConversationRequest,
)
def _derive_risk(messages) -> str | None:
"""Derive aggregate risk level from conversation messages.
Heuristic: if any tool_call has dangerous args (env_id containing 'prod',
'execute', 'deploy', 'maintenance'), classify as 'dangerous'.
If a tool was called at all, classify as 'guarded'. Otherwise 'safe'.
"""
has_tool = False
dangerous_keywords = {"prod", "execute", "deploy", "maintenance", "migration", "backup"}
for m in messages:
tool_calls = getattr(m, "tool_calls", None)
if tool_calls and (isinstance(tool_calls, (list, tuple)) and len(tool_calls) > 0):
has_tool = True
for tc in tool_calls:
if isinstance(tc, dict):
args_str = str(tc.get("input", ""))
else:
args_str = str(getattr(tc, "input", ""))
if any(kw in args_str.lower() for kw in dangerous_keywords):
return "dangerous"
if has_tool:
return "guarded"
return "safe"
def _safe_str(val, default=None):
"""Coerce value to string, falling back to default for non-string types."""
if val is None:
return default
if isinstance(val, str):
return val
try:
return str(val)
except Exception:
return default
def _safe_bool(val):
"""Coerce value to bool safely."""
if isinstance(val, bool):
return val
if val is None:
return False
try:
return bool(val)
except Exception:
return False
def _text_has_error(text: str) -> bool:
"""Check if message text indicates an error state.
Matches: [ERROR] markers, ⏹️ cancelled operations, Russian/English
error phrases like 'недоступен', 'unavailable', 'ошибка', etc.
"""
t = str(text).lower() if text else ""
markers = [
"[error]",
"⏹️",
"недоступен",
"unavailable",
"временно недоступен",
"temporarily unavailable",
"попробуйте позже",
"try again later",
"агент временно",
"agent is temporarily",
"произошла ошибка",
"an error occurred",
]
return any(m in t for m in markers)
router = APIRouter(prefix="/api/assistant", tags=["Agent"])
agent_router = APIRouter(prefix="/api/agent", tags=["Agent-Internal"])
@@ -49,8 +122,25 @@ async def list_conversations(
(page - 1) * page_size
).limit(page_size).all()
return ConversationListResponse(
items=[ConversationItem(id=c.id, title=c.title, updated_at=c.updated_at,
message_count=len(c.messages)) for c in items],
items=[ConversationItem(
id=c.id,
title=c.title,
updated_at=c.updated_at,
message_count=len(c.messages) if c.messages else 0,
last_role=_safe_str(getattr(c.messages[-1], "role", None)) if c.messages else None,
has_tool_calls=any(
getattr(m, "tool_calls", None) and (
isinstance(getattr(m, "tool_calls"), (list, tuple)) and len(getattr(m, "tool_calls")) > 0
)
for m in (c.messages or [])
),
has_error=any(
(_safe_str(getattr(m, "state", None)) in ("error", "failed"))
or (_safe_str(getattr(m, "text", None)) and _text_has_error(getattr(m, "text", "")))
for m in (c.messages or [])
),
risk_level=_derive_risk(c.messages) if c.messages else None,
) for c in items],
has_next=(page * page_size) < total,
active_total=total,
)
@@ -103,6 +193,7 @@ async def save_conversation(
conversation_id=body.conversation_id,
role=msg_data.get("role", "user"),
text=msg_data.get("text", ""),
state=msg_data.get("state"),
tool_calls=msg_data.get("tool_calls"),
attachments=msg_data.get("attachments"),
created_at=datetime.utcnow(),

View File

@@ -14,6 +14,10 @@ class ConversationItem(BaseModel):
title: str
updated_at: datetime
message_count: int
last_role: str | None = None # "user" | "assistant" — who sent the last message
has_tool_calls: bool = False # conversation includes tool usage
has_error: bool = False # conversation has error state
risk_level: str | None = None # "safe" | "guarded" | "dangerous"
# #endregion Schemas.Agent.ConversationItem

View File

@@ -0,0 +1,129 @@
# backend/tests/agent/test_title_cleaning.py
# #region Test.Agent.Persistence.CleanTitle [C:2] [TYPE Module] [SEMANTICS test,agent,persistence,title]
# @BRIEF Unit tests for rule-based conversation title cleaning — all edge cases.
# @RELATION BINDS_TO -> [AgentChat.Persistence.CleanTitle]
# @TEST_EDGE: empty_input -> "Новый диалог"
# @TEST_EDGE: whitespace_only -> "Новый диалог"
# @TEST_EDGE: file_markers -> stripped before first marker
# @TEST_EDGE: prefetch_markers -> stripped before first marker
# @TEST_EDGE: hitl_title -> preserved as-is
# @TEST_EDGE: json_input -> "Данные: {...}"
# @TEST_EDGE: url_input -> domain extracted
# @TEST_EDGE: long_text -> truncated at 80 with word boundary
# @TEST_EDGE: code_input -> first line preserved
import pytest
from src.agent._persistence import clean_title
class TestCleanTitle:
"""Rule-based title cleaning — all edge cases from the spec matrix."""
# ── Empty / whitespace ──
def test_empty_string(self):
assert clean_title("") == "Новый диалог"
def test_whitespace_only(self):
assert clean_title(" \n \t ") == "Новый диалог"
def test_none_input(self):
assert clean_title(None) == "Новый диалог"
# ── Short / normal ──
def test_short_greeting(self):
assert clean_title("Привет") == "Привет"
def test_emoji_only(self):
assert clean_title("🔥") == "🔥"
def test_normal_russian(self):
assert clean_title("Покажи доступные дашборды") == "Покажи доступные дашборды"
def test_normal_english(self):
assert clean_title("Show me all dashboards in prod") == "Show me all dashboards in prod"
# ── File markers ──
def test_file_marker_stripped(self):
result = clean_title("Покажи дашборды\n--- Uploaded file content ---\nid,name\n1,A")
assert result == "Покажи дашборды"
def test_only_file_marker(self):
result = clean_title("--- Uploaded file content ---\nid,name\n1,A")
assert result == "Новый диалог"
# ── Pre-fetch markers ──
def test_prefetch_marker_stripped(self):
result = clean_title("Мигрируй 42\n[PRE-FETCHED DATA]\nAvailable dashboards...")
assert result == "Мигрируй 42"
def test_prefetch_end_marker_stripped(self):
result = clean_title("Поиск\n[/PRE-FETCHED DATA]\nextra")
assert result == "Поиск"
# ── HITL titles (preserved) ──
def test_hitl_confirm_preserved(self):
assert clean_title("✅ get_health_summary") == "✅ get_health_summary"
def test_hitl_deny_preserved(self):
assert clean_title("⏹️ show_capabilities") == "⏹️ show_capabilities"
# ── JSON / CSV / URL detection ──
def test_json_input(self):
result = clean_title('{"id": 1, "name": "Dashboard A"}')
assert result.startswith("Данные: ")
def test_csv_input(self):
result = clean_title("id,name,status\n1,Dashboard A,active")
# CSV under 80 chars, first line kept as-is
assert result == "id,name,status"
def test_url_input(self):
result = clean_title("https://superset.example.com/dashboard/42")
# Should extract domain
assert "superset.example.com" in result
# ── Sentence cut ──
def test_cut_at_first_sentence(self):
result = clean_title(
"Чтобы проанализировать систему, мне нужно проверить health. "
"Для начала покажи список дашбордов."
)
assert "Чтобы проанализировать систему, мне нужно проверить health." in result
assert "Для начала" not in result
def test_cut_at_exclamation(self):
result = clean_title("Срочно! Проверь статус системы.")
assert result == "Срочно!"
def test_cut_at_question(self):
result = clean_title("Как работает миграция? Нужно подробное описание.")
assert result == "Как работает миграция?"
# ── Long text truncation ──
def test_long_text_truncated(self):
long_text = "Это очень длинное сообщение про миграцию дашбордов из окружения dev в prod с заменой конфигов и фиксом кросс-фильтров"
result = clean_title(long_text)
assert len(result) <= 80
assert result.endswith("")
def test_long_text_no_spaces(self):
result = clean_title("A" * 200)
assert len(result) <= 80
# ── Code detection ──
def test_code_def(self):
result = clean_title("def migrate(): pass\n\nMore text here")
assert result == "def migrate(): pass"
def test_code_import(self):
result = clean_title("import os\nimport sys\n\nShow dashboards")
assert result == "import os"
# ── Already clean ──
def test_already_clean_title(self):
assert clean_title("CSV-файл: Анализ дашбордов") == "CSV-файл: Анализ дашбордов"
# ── Edge: no dot at end ──
def test_single_sentence_no_punctuation(self):
result = clean_title("Проверь статус системы ss-dev")
assert result == "Проверь статус системы ss-dev"
# #endregion Test.Agent.Persistence.CleanTitle

View File

@@ -61,11 +61,16 @@ class TestListConversations:
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 2
_NOW = __import__("datetime").datetime.now()
mock_msg = MagicMock()
mock_msg.role = "user"
mock_msg.tool_calls = []
mock_msg.state = None
mock_msg.text = "Hello"
mock_conv = MagicMock()
mock_conv.id = "conv-1"
mock_conv.title = "Test Conversation"
mock_conv.updated_at = _NOW
mock_conv.messages = [MagicMock()]
mock_conv.messages = [mock_msg]
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [mock_conv, mock_conv]
from src.core.database import get_db

View File

@@ -4,9 +4,10 @@
<!-- @RELATION BINDS_TO -> AgentChat.Model -->
<!-- @RELATION DEPENDS_ON -> $lib/ui/Icon -->
<!-- @RELATION DEPENDS_ON -> AgentChat.ConversationList -->
<!-- @RELATION DEPENDS_ON -> AgentChat.ConfirmationCard -->
<!-- @UX_STATE idle -> Welcome screen with suggestion chips, input enabled. -->
<!-- @UX_STATE streaming -> Streaming bubble with cursor animation, input locked. -->
<!-- @UX_STATE awaiting_confirmation -> Confirmation card with confirm/deny buttons. -->
<!-- @UX_STATE awaiting_confirmation -> Delegates to ConfirmationCard (tool name, args, confirm/deny, a11y, keyboard). -->
<!-- @UX_STATE error -> Error banner with retry button. -->
<!-- @UX_STATE disconnected -> Reconnect button, input locked. -->
<!-- @INVARIANT Messages from current conversation only. -->
@@ -17,6 +18,7 @@
import MarkdownRenderer from "$lib/components/assistant/MarkdownRenderer.svelte";
import ConnectionIndicator from "$lib/components/assistant/ConnectionIndicator.svelte";
import ToolCallCard from "$lib/components/assistant/ToolCallCard.svelte";
import ConfirmationCard from "$lib/components/assistant/ConfirmationCard.svelte";
import type { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts";
import { addToast } from "$lib/toasts.svelte.js";
@@ -32,8 +34,8 @@
let messagesContainer: HTMLDivElement | undefined = $state();
let fileInputRef: HTMLInputElement | undefined = $state();
let pendingFile: File | null = $state(null);
let sidebarOpen = $state(false);
let debugOpen = $state(true);
let isDragOver = $state(false);
let dragCounter = 0;
const ALLOWED_FILE_TYPES = [".pdf", ".xlsx", ".json", ".csv", ".txt", ".png", ".jpeg", ".jpg"];
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
@@ -48,12 +50,6 @@
model.streamingState === "idle" &&
!model.isLoadingHistory
);
let conversationIdLabel = $derived(model.currentConversationId || "new");
let pendingThreadIdLabel = $derived(model.pendingThreadId || "none");
let userIdLabel = $derived(model.userId || "anonymous");
let envIdLabel = $derived(model.envId || "default");
let lastMessageIdLabel = $derived(model.messages.at(-1)?.id || "none");
let messageCountLabel = $derived(String(model.messages.length));
// ── Track streaming completion → commit messages ──────────────
let _prevStreamState = $state("");
@@ -103,7 +99,7 @@
},
];
model._persistMessages?.();
} else {
} else if (!model._userCancelled) {
// Empty response — show fallback error message (FR-003 edge case)
const fallbackText = model.error || "Агент временно недоступен. Попробуйте позже.";
model.messages = [
@@ -122,6 +118,21 @@
model.partialText = "";
model.partialTokens = [];
model.activeToolCalls = [];
// Attach file metadata to the last user message (file_uploaded event)
if (model._pendingAttachment) {
const attachment = model._pendingAttachment;
model._pendingAttachment = null;
// Walk backwards to find the most recent user message
for (let i = model.messages.length - 1; i >= 0; i--) {
if (model.messages[i].role === "user") {
model.messages[i] = { ...model.messages[i], attachment };
model.messages = [...model.messages]; // trigger reactivity
model._persistMessages?.();
break;
}
}
}
}
_prevStreamState = currentState;
@@ -146,20 +157,19 @@
const hasFile = pendingFile !== null;
if ((!text && !hasFile) || isInputLocked) return;
// Optimistically add user message (skip if no text and only file)
if (text) {
model.messages = [
...model.messages,
{
id: `user-${Date.now()}`,
conversation_id: model.currentConversationId || "",
role: "user",
text: text,
toolCalls: [],
created_at: new Date().toISOString(),
},
];
}
// Always show a user bubble — for file-only sends, include the filename
const displayText = text || (hasFile ? `📎 ${pendingFile!.name}` : "");
model.messages = [
...model.messages,
{
id: `user-${Date.now()}`,
conversation_id: model.currentConversationId || "",
role: "user",
text: displayText,
toolCalls: [],
created_at: new Date().toISOString(),
},
];
inputText = "";
const filesToSend: File[] | undefined = pendingFile ? [pendingFile] : undefined;
@@ -182,10 +192,12 @@
const ext = "." + file.name.split(".").pop()?.toLowerCase();
if (!ALLOWED_FILE_TYPES.includes(ext)) {
addToast(`Формат ${ext} не поддерживается. Разрешены: ${ALLOWED_FILE_TYPES.join(", ")}`, "error");
target.value = "";
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
addToast(`Файл слишком большой (макс. ${formatFileSize(MAX_FILE_SIZE_BYTES)})`, "error");
target.value = "";
return;
}
@@ -198,6 +210,53 @@
if (fileInputRef) fileInputRef.value = "";
}
// ── Drag & drop ────────────────────────────────────────────────
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
}
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragCounter++;
if (e.dataTransfer?.types.includes("Files")) {
isDragOver = true;
}
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
isDragOver = false;
}
}
function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
isDragOver = false;
dragCounter = 0;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const file = files[0];
const ext = "." + file.name.split(".").pop()?.toLowerCase();
if (!ALLOWED_FILE_TYPES.includes(ext)) {
addToast(`Формат ${ext} не поддерживается. Разрешены: ${ALLOWED_FILE_TYPES.join(", ")}`, "error");
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
addToast(`Файл слишком большой (макс. ${formatFileSize(MAX_FILE_SIZE_BYTES)})`, "error");
return;
}
pendingFile = file;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
@@ -209,16 +268,11 @@
handleSend();
}
// ── Suggestion chips ───────────────────────────────────────────
const suggestions = [
{ label: "Покажи дашборды", text: "Покажи доступные дашборды" },
{ label: "Запустить миграцию", text: "Запусти миграцию" },
{ label: "Статус задач", text: "Проверь статус текущих задач" },
];
// Toggle conversation sidebar
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
function phaseClasses(tone: string): string {
if (tone === "success") return "border-success bg-success-light text-success";
if (tone === "warning") return "border-warning bg-warning-light text-warning";
if (tone === "destructive") return "border-destructive-ring bg-destructive-light text-destructive";
return "border-border bg-surface-muted text-text-muted";
}
async function copyDebugInfo() {
@@ -253,8 +307,8 @@
<div class="flex min-w-0 items-center gap-3">
<button
class="shrink-0 rounded-lg p-1.5 text-text-muted transition hover:bg-surface-muted hover:text-text md:hidden"
onclick={toggleSidebar}
aria-label="Toggle sidebar"
onclick={() => model.toggleConversationSidebar()}
aria-label="Открыть список диалогов"
>
<Icon name="menu" size={20} />
</button>
@@ -264,12 +318,10 @@
title={model.connectionState === "connected" ? "Agent connected" : "Agent disconnected"}
/>
<h1 class="truncate text-sm font-semibold text-text">
{model.currentConversationId
? (model.conversations.find(c => c.id === model.currentConversationId)?.title || "Агент-чат")
: "Агент-чат"}
{model.currentConversationTitle}
</h1>
<span class="hidden rounded-md border border-border bg-surface-page px-2 py-0.5 font-mono text-[11px] text-text-muted sm:inline">
conv:{conversationIdLabel.slice(0, 8)}
conv:{model.conversationIdLabel.slice(0, 8)}
</span>
</div>
{#if model.connectionState === "disconnected"}
@@ -284,10 +336,10 @@
<div class="flex shrink-0 items-center gap-2">
<button
class={`rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${debugOpen
class={`rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${model.isDebugPanelOpen
? "border-primary-ring bg-primary-light text-primary"
: "border-border-strong bg-surface-card text-text hover:bg-surface-muted"}`}
onclick={() => debugOpen = !debugOpen}
onclick={() => model.toggleDebugPanel()}
title="Agent debug information"
>
<div class="flex items-center gap-1.5">
@@ -296,9 +348,10 @@
</div>
</button>
<button
class="rounded-lg border border-border-strong bg-surface-card px-2.5 py-1.5 text-xs font-medium text-text transition hover:bg-surface-muted"
class="hidden rounded-lg border border-border-strong bg-surface-card px-2.5 py-1.5 text-xs font-medium text-text transition hover:bg-surface-muted sm:inline-flex"
onclick={copyDebugInfo}
title="Copy agent debug information"
aria-label="Copy agent debug information"
>
<div class="flex items-center gap-1.5">
<Icon name="copy" size={14} />
@@ -332,15 +385,37 @@
</div>
</div>
{#if debugOpen}
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
<span class={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-medium ${phaseClasses(model.agentPhaseTone)}`}>
<ConnectionIndicator
color={model.connectionDotColor as "success" | "warning" | "destructive"}
title={model.connectionState === "connected" ? "Agent connected" : "Agent disconnected"}
/>
{$t.assistant?.phases?.[model.agentPhase] || model.agentPhase}
</span>
{#if model.envIdLabel !== "—"}
<span class="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface-page px-2 py-1 text-text-muted">
<Icon name="database" size={13} />
{$t.assistant?.environment || "Окружение"}: <span class="font-mono text-text">{model.envIdLabel}</span>
</span>
{/if}
{#if model.activeToolCalls.length > 0}
<span class="inline-flex items-center gap-1.5 rounded-md border border-warning bg-warning-light px-2 py-1 text-warning">
<Icon name="activity" size={13} />
{$t.assistant?.active_tools || "Инструменты"}: {model.activeToolCalls.length}
</span>
{/if}
</div>
{#if model.isDebugPanelOpen}
<div class="mt-3 grid gap-2 rounded-lg border border-border bg-surface-page px-3 py-2 text-[11px] text-text-muted sm:grid-cols-2 lg:grid-cols-4">
<div class="min-w-0">
<span class="font-medium text-text-subtle">conv_id</span>
<div class="truncate font-mono text-text" title={conversationIdLabel}>{conversationIdLabel}</div>
<div class="truncate font-mono text-text" title={model.conversationIdLabel}>{model.conversationIdLabel}</div>
</div>
<div class="min-w-0">
<span class="font-medium text-text-subtle">thread_id</span>
<div class="truncate font-mono text-text" title={pendingThreadIdLabel}>{pendingThreadIdLabel}</div>
<div class="truncate font-mono text-text" title={model.pendingThreadIdLabel}>{model.pendingThreadIdLabel}</div>
</div>
<div class="min-w-0">
<span class="font-medium text-text-subtle">state</span>
@@ -348,15 +423,15 @@
</div>
<div class="min-w-0">
<span class="font-medium text-text-subtle">env / user</span>
<div class="truncate font-mono text-text" title={`${envIdLabel} / ${userIdLabel}`}>{envIdLabel} / {userIdLabel}</div>
<div class="truncate font-mono text-text" title={`${model.envIdLabel} / ${model.userIdLabel}`}>{model.envIdLabel} / {model.userIdLabel}</div>
</div>
<div class="min-w-0">
<span class="font-medium text-text-subtle">messages</span>
<div class="truncate font-mono text-text">{messageCountLabel}</div>
<div class="truncate font-mono text-text">{model.messageCountLabel}</div>
</div>
<div class="min-w-0">
<span class="font-medium text-text-subtle">last_msg</span>
<div class="truncate font-mono text-text" title={lastMessageIdLabel}>{lastMessageIdLabel}</div>
<div class="truncate font-mono text-text" title={model.lastMessageIdLabel}>{model.lastMessageIdLabel}</div>
</div>
<div class="min-w-0">
<span class="font-medium text-text-subtle">tools</span>
@@ -377,26 +452,36 @@
>
<!-- Welcome screen -->
{#if showWelcome}
<div class="flex flex-col items-center justify-center pt-12 pb-8">
<div class="mx-auto flex w-full max-w-3xl flex-col items-center justify-center pt-10 pb-8">
<!-- Avatar/Logo -->
<div class="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-light text-primary shadow-sm">
<div class="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-light text-primary shadow-sm">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/>
</svg>
</div>
<h2 class="text-xl font-semibold text-text mb-2">{$t.assistant?.welcome_title || "Чем могу помочь?"}</h2>
<p class="text-sm text-text-muted text-center max-w-md mb-8">
<h2 class="text-xl font-semibold text-text mb-2">{$t.assistant?.welcome_title || "Готов к работе"}</h2>
<p class="text-sm text-text-muted text-center max-w-md mb-6">
{model.conversations.length === 0
? "Задайте вопрос или выберите одно из предложений ниже."
: "Продолжите диалог или начните новый."}
? ($t.assistant?.welcome_empty_hint || "Задайте вопрос или выберите рабочий сценарий.")
: ($t.assistant?.welcome_continue_hint || "Продолжите диалог или начните новый.")}
</p>
<div class="flex flex-wrap gap-2 justify-center">
{#each suggestions as s}
<div class="grid w-full gap-2 sm:grid-cols-2">
{#each model.quickActions as action}
<button
class="rounded-xl border border-primary-ring bg-primary-light px-4 py-2.5 text-sm font-medium text-primary transition hover:bg-primary hover:text-white"
onclick={() => handleSuggest(s.text)}
class="group flex min-h-[76px] items-start gap-3 rounded-lg border border-border bg-surface-card px-4 py-3 text-left transition hover:border-primary-ring hover:bg-primary-light focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring"
onclick={() => handleSuggest(action.prompt)}
>
{s.label}
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-surface-muted text-primary group-hover:bg-white">
<Icon name={action.icon} size={17} />
</span>
<span class="min-w-0">
<span class="block text-sm font-semibold text-text">
{$t.assistant?.[action.labelKey] || action.labelKey}
</span>
<span class="mt-1 block text-xs leading-5 text-text-muted">
{$t.assistant?.[action.descriptionKey] || action.descriptionKey}
</span>
</span>
</button>
{/each}
</div>
@@ -410,6 +495,23 @@
<!-- User bubble -->
<div class="max-w-[75%] rounded-2xl bg-primary px-4 py-3 text-white shadow-sm">
<p class="text-sm whitespace-pre-wrap leading-relaxed">{msg.text}</p>
{#if msg.attachment}
<a
href={`/api/storage/file?path=${encodeURIComponent(msg.attachment.file_path)}`}
download={msg.attachment.file_name}
class="mt-2 flex items-center gap-2 rounded-lg bg-white/15 px-3 py-2 text-xs font-medium text-white transition hover:bg-white/25"
onclick={(e) => e.stopPropagation()}
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span class="truncate max-w-[180px]">{msg.attachment.file_name}</span>
<span class="shrink-0 text-white/60">{formatFileSize(msg.attachment.file_size)}</span>
<svg class="w-3.5 h-3.5 shrink-0 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
</a>
{/if}
</div>
{:else if msg.role === "tool"}
<!-- Tool message: inline code block -->
@@ -535,36 +637,11 @@
</div>
{/if}
<!-- Awaiting confirmation -->
{#if model.streamingState === "awaiting_confirmation"}
<div class="flex justify-start">
<div class="max-w-[80%] rounded-2xl border border-warning bg-warning-light px-4 py-4 shadow-sm" role="alertdialog">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-warning shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<span class="text-sm font-semibold text-text">
{$t.assistant?.confirmation_card_title || "Требуется подтверждение"}
</span>
</div>
<p class="text-sm text-text-muted mb-3">{model.error || "Подтвердите выполнение действия"}</p>
<div class="flex gap-2">
<button
class="rounded-lg border border-success bg-white px-4 py-2 text-xs font-semibold text-success transition hover:bg-success-light"
onclick={() => model.resumeConfirm("confirm")}
>
{$t.assistant?.confirm || "Подтвердить"}
</button>
<button
class="rounded-lg border border-destructive-ring bg-white px-4 py-2 text-xs font-semibold text-destructive transition hover:bg-destructive-light"
onclick={() => model.resumeConfirm("deny")}
>
{$t.assistant?.cancel || "Отклонить"}
</button>
</div>
</div>
</div>
{/if}
<!-- Awaiting confirmation — keyed on thread_id to force fresh component on each HITL -->
{#key model.pendingThreadId}
<ConfirmationCard {model} />
{/key}
<!-- Error -->
{#if model.streamingState === "error" && model.error}
@@ -601,7 +678,12 @@
</svg>
<span class="truncate max-w-[200px]">{pendingFile.name}</span>
<span class="text-text-subtle">({formatFileSize(pendingFile.size)})</span>
<button class="ml-1 rounded p-0.5 text-text-subtle hover:text-destructive" onclick={removeFile}>
<button
class="ml-1 rounded p-0.5 text-text-subtle hover:text-destructive"
onclick={removeFile}
aria-label={$t.assistant?.remove_attachment || "Удалить вложение"}
title={$t.assistant?.remove_attachment || "Удалить вложение"}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
@@ -611,8 +693,29 @@
{/if}
<div class="flex items-end gap-2">
<div class="relative flex-1">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative flex-1"
ondragover={handleDragOver}
ondragenter={handleDragEnter}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
{#if isDragOver}
<div class="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary-ring bg-primary-light/30 pointer-events-none">
<div class="flex flex-col items-center gap-1.5 text-primary">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
</svg>
<span class="text-sm font-semibold">Перетащите файл сюда</span>
<span class="text-xs opacity-70">{ALLOWED_FILE_TYPES.join(", ")} (макс. {formatFileSize(MAX_FILE_SIZE_BYTES)})</span>
</div>
</div>
{/if}
<textarea
id="agent-chat-input"
name="agent_chat_input"
aria-label={$t.assistant?.input_placeholder || "Введите команду..."}
bind:value={inputText}
rows="1"
placeholder={$t.assistant?.input_placeholder || "Введите команду..."}
@@ -640,6 +743,8 @@
</svg>
</button>
<input
id="agent-chat-file"
name="agent_chat_file"
type="file"
accept=".pdf,.xlsx,.json,.csv,.txt,.png,.jpeg,.jpg"
class="hidden"

View File

@@ -0,0 +1,203 @@
<!-- #region AgentChat.ConfirmationCard [C:3] [TYPE Component] [SEMANTICS agent-chat,hitl,guardrails,confirmation] -->
<!-- @ingroup AgentChat -->
<!-- @BRIEF HITL guardrails card — shows pending tool name/args with confirm/deny buttons. A11Y-compliant, keyboard-navigable, loading states. -->
<!-- @RELATION DEPENDS_ON -> $lib/ui/Button -->
<!-- @RELATION DEPENDS_ON -> $lib/ui/Icon -->
<!-- @RELATION BINDS_TO -> AgentChat.Model -->
<!-- @UX_STATE awaiting_confirmation -> Card with warning icon, tool name, args preview, confirm/deny buttons. Focus auto-trapped to buttons. -->
<!-- @UX_FEEDBACK Toast on confirm/deny dispatch. Button spinner on loading. -->
<!-- @UX_RECOVERY Escape = deny, Enter = confirm (via keyboard). Buttons always visible. -->
<!-- @UX_REACTIVITY Props -> $props(model), LocalState -> $state(isLoading), $derived from model.pendingToolName/Args. -->
<!-- @INVARIANT Card only renders when model.streamingState === "awaiting_confirmation". -->
<!-- @INVARIANT Buttons use $lib/ui/Button with proper semantic variants. -->
<!-- @INVARIANT A11Y: role="alertdialog", aria-labelledby, aria-describedby, auto-focus, Escape handler. -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import Button from "$lib/ui/Button.svelte";
import Icon from "$lib/ui/Icon.svelte";
import type { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts";
let { model }: { model: AgentChatModel } = $props();
let loading = $state<"idle" | "confirming" | "denying">("idle");
let cardEl: HTMLDivElement | undefined = $state();
// ── Derived: tool call preview ──────────────────────────────
let hasArgs = $derived(
model.pendingToolArgs && Object.keys(model.pendingToolArgs).length > 0,
);
let argEntries = $derived(
hasArgs ? Object.entries(model.pendingToolArgs as Record<string, unknown>).slice(0, 6) : [],
);
let fallbackDescription = $derived(
$t.assistant?.[model.confirmationDescriptionKey] || "Разрешить агенту выполнить действие?",
);
let titleText = $derived(
model.confirmationMessageOverride ||
$t.assistant?.[model.confirmationTitleKey] ||
$t.assistant?.confirmation_card_title ||
"Требуется подтверждение",
);
let toneClasses = $derived(
model.confirmationTone === "success"
? {
card: "border-success bg-success-light",
icon: "bg-success/15 text-success",
details: "border-success/30 bg-white/70",
badge: "bg-success-light text-success",
}
: 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",
}
: {
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) {
const firstBtn = cardEl.querySelector<HTMLButtonElement>("button");
firstBtn?.focus();
}
});
// ── Keyboard handler: Enter → confirm, Escape → deny ──────
function handleKeydown(e: KeyboardEvent) {
if (loading !== "idle") return;
if (e.key === "Enter") {
e.preventDefault();
handleConfirm();
} else if (e.key === "Escape") {
e.preventDefault();
handleDeny();
}
}
function handleWindowKeydown(e: KeyboardEvent) {
if (model.streamingState !== "awaiting_confirmation" || loading !== "idle") return;
if (e.key !== "Escape") return;
e.preventDefault();
handleDeny();
}
// ── Actions ────────────────────────────────────────────────
async function handleConfirm() {
if (loading !== "idle") return;
loading = "confirming";
try {
await model.resumeConfirm("confirm");
} finally {
loading = "idle";
}
}
async function handleDeny() {
if (loading !== "idle") return;
loading = "denying";
try {
await model.resumeConfirm("deny");
} finally {
loading = "idle";
}
}
</script>
<svelte:window onkeydown={handleWindowKeydown} />
{#if model.streamingState === "awaiting_confirmation"}
<div class="flex justify-start">
<div
bind:this={cardEl}
class={`w-full max-w-[92%] rounded-2xl border px-5 py-4 shadow-sm sm:max-w-[85%] ${toneClasses.card}`}
role="alertdialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-desc"
onkeydown={handleKeydown}
tabindex="-1"
>
<!-- ── Header ─────────────────────────────────────────── -->
<div class="flex items-start gap-3 mb-3">
<div class={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${toneClasses.icon}`}>
<Icon name={model.confirmationRisk === "read" ? "database" : "warning"} size={18} />
</div>
<div class="min-w-0">
<h3 id="confirm-title" class="text-sm font-semibold text-text">
{titleText}
</h3>
<p id="confirm-desc" class="mt-0.5 text-xs text-text-subtle">
{fallbackDescription}
</p>
</div>
</div>
<!-- ── Tool info ─────────────────────────────────────── -->
{#if model.pendingToolName}
<div class={`mb-3 rounded-lg border px-3.5 py-2.5 ${toneClasses.details}`}>
<div class="flex items-center gap-2 text-sm font-medium text-text">
<Icon name="code" size={14} class="text-text-muted" />
<span class="truncate">{model.pendingToolLabel}</span>
<code class={`rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold ${toneClasses.badge}`} title={model.pendingToolName}>
{model.pendingToolName}
</code>
</div>
{#if argEntries.length > 0}
<div class="mt-2 space-y-1">
{#each argEntries as [key, value]}
<div class="flex items-baseline gap-2 text-xs">
<span class="shrink-0 font-mono text-text-muted">{key}:</span>
<span class="truncate text-text" title={String(value)}>{String(value).slice(0, 120)}</span>
</div>
{/each}
{#if Object.keys(model.pendingToolArgs).length > 6}
<p class="text-[11px] text-text-subtle">
… и ещё {Object.keys(model.pendingToolArgs).length - 6} параметров
</p>
{/if}
</div>
{/if}
</div>
{/if}
<!-- ── 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>
<Button
variant="ghost"
size="sm"
isLoading={loading === "denying"}
disabled={loading !== "idle"}
onclick={handleDeny}
class="text-destructive hover:bg-destructive-light hover:text-destructive"
>
{loading === "denying"
? ($t.assistant?.cancelling || "Отмена…")
: ($t.assistant?.cancel || "Отклонить")}
</Button>
</div>
<!-- ── Keyboard hint ────────────────────────────────── -->
<p class="mt-2.5 text-[11px] text-text-subtle text-center">
↵ Подтвердить &middot; ⎋ Отклонить
</p>
</div>
</div>
{/if}
<!-- #endregion AgentChat.ConfirmationCard -->

View File

@@ -1,14 +1,15 @@
<!-- #region AgentChat.ConversationList [C:3] [TYPE Component] [SEMANTICS agent,conversations,list,sidebar,ui] -->
<!-- #region AgentChat.ConversationList [C:4] [TYPE Component] [SEMANTICS agent,conversations,list,sidebar,ui,indicators] -->
<!-- @ingroup AgentChat -->
<!-- @BRIEF Sidebar conversation list — search, infinite scroll, date grouping, archive action. -->
<!-- @BRIEF Sidebar conversation list — search, infinite scroll, date grouping, visual indicators (status dot, tool/error icons, relative time, risk stripe). -->
<!-- @UX_STATE loading — skeleton cards (3-5, animate-pulse bg-surface-muted). -->
<!-- @UX_STATE loaded — grouped list with conversation titles and dates. -->
<!-- @UX_STATE loaded — grouped list with indicators: status color, tool icon, error icon, message badge, relative time. -->
<!-- @UX_STATE empty — "Нет диалогов" placeholder. -->
<!-- @UX_STATE error — retry button. -->
<!-- @RELATION DEPENDS_ON -> $lib/ui/Input (existing) -->
<!-- @RELATION DEPENDS_ON -> $lib/ui/Button variant="ghost" (existing) -->
<!-- @RELATION DEPENDS_ON -> $lib/ui/Input -->
<!-- @RELATION DEPENDS_ON -> $lib/ui/Button variant="ghost" -->
<!-- @RELATION DEPENDS_ON -> $lib/ui/Icon -->
<!-- @RELATION BINDS_TO -> AgentChat.Model -->
<!-- Design tokens: sidebar=bg-surface-card border-r border-border, skeleton=animate-pulse bg-surface-muted -->
<!-- Design tokens: sidebar=bg-surface-card border-r border-border, status-dot=w-1 absolute left-0, risk stripe via border-l-{semantic color} -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import Input from "$lib/ui/Input.svelte";
@@ -16,6 +17,17 @@
import Icon from "$lib/ui/Icon.svelte";
import { ConfirmDialog } from "$lib/ui";
interface ConversationItem {
id: string;
title: string;
updated_at: string;
message_count?: number;
last_role?: string | null;
has_tool_calls?: boolean;
has_error?: boolean;
risk_level?: string | null;
}
let {
conversations = [],
currentConversationId = null,
@@ -25,8 +37,9 @@
ondelete = undefined,
onloadmore = undefined,
onsearch = undefined,
searchFieldId = "agent-conversation-search",
}: {
conversations: Array<{ id: string; title: string; updated_at: string; message_count?: number }>;
conversations: ConversationItem[];
currentConversationId: string | null;
isLoading: boolean;
hasNext: boolean;
@@ -34,6 +47,7 @@
ondelete?: (id: string) => void;
onloadmore?: () => void;
onsearch?: (query: string) => void;
searchFieldId?: string;
} = $props();
let searchQuery = $state("");
@@ -41,7 +55,39 @@
let showDeleteConfirm = $state(false);
let pendingDeleteId: string | null = $state(null);
// Group conversations by date on client
// ── Relative time formatter ──────────────────────────────────
function relativeTime(iso: string): string {
if (!iso) return "";
const then = new Date(iso).getTime();
const now = Date.now();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMs / 3600000);
if (diffMs < 60000) return $t.assistant?.time_just_now || "только что";
if (diffMin < 60) return `${diffMin} ${$t.assistant?.time_min || "мин"}`;
if (diffHr < 24) return `${diffHr} ${$t.assistant?.time_hr || "ч"}`;
return "";
}
// ── Status dot color ─────────────────────────────────────────
function statusDotClass(item: ConversationItem): string {
if (item.has_error) return "bg-destructive";
if (item.risk_level === "dangerous") return "bg-destructive";
if (item.has_tool_calls || item.risk_level === "guarded") return "bg-warning";
if (item.last_role === "user") return "bg-info";
return "bg-success";
}
// ── Risk stripe (left border accent) ─────────────────────────
function riskStripeClass(item: ConversationItem): string {
if (item.risk_level === "dangerous") return "border-l-destructive";
if (item.risk_level === "guarded" || item.has_tool_calls) return "border-l-warning";
if (item.has_error) return "border-l-destructive";
return "border-l-transparent";
}
// ── Group conversations by date ──────────────────────────────
let groupedConversations = $derived.by(() => {
const groups: Map<string, typeof conversations> = new Map();
const today = new Date();
@@ -52,14 +98,14 @@
thisWeek.setDate(thisWeek.getDate() - 7);
for (const convo of conversations) {
const d = new Date(convo.updated_at || convo.created_at || Date.now());
const d = new Date(convo.updated_at || Date.now());
let label: string;
if (d >= today) {
label = $t.assistant?.today || "Today";
label = $t.assistant?.today || "Сегодня";
} else if (d >= yesterday) {
label = $t.assistant?.yesterday || "Yesterday";
label = $t.assistant?.yesterday || "Вчера";
} else if (d >= thisWeek) {
label = $t.assistant?.this_week || "This week";
label = $t.assistant?.this_week || "Эта неделя";
} else {
label = d.toLocaleDateString();
}
@@ -88,7 +134,10 @@
<!-- Search -->
<div class="p-2 border-b border-border">
<Input
placeholder={$t.assistant?.search_placeholder || "Search conversations..."}
id={searchFieldId}
name={searchFieldId.replaceAll("-", "_")}
aria-label={$t.assistant?.search_placeholder || "Поиск диалогов..."}
placeholder={$t.assistant?.search_placeholder || "Поиск диалогов..."}
value={searchQuery}
oninput={(e) => handleSearch((e.target as HTMLInputElement).value)}
/>
@@ -100,41 +149,81 @@
<!-- Skeleton -->
<div class="space-y-2 p-3">
{#each Array(5) as _, i}
<div key={i} class="animate-pulse bg-surface-muted rounded-lg h-12"></div>
<div key={i} class="animate-pulse bg-surface-muted rounded-lg h-14"></div>
{/each}
</div>
{:else if conversations.length === 0}
<!-- Empty -->
<div class="p-4 text-sm text-text-muted text-center">
{$t.assistant?.no_conversations || "No conversations"}
{$t.assistant?.no_conversations || "Нет диалогов"}
</div>
{:else}
<!-- Grouped list -->
{#each groupedConversations as [label, items]}
<div class="px-3 pt-3 pb-1">
<span class="text-text-muted text-xs font-semibold uppercase">{label}</span>
<span class="text-text-muted text-xs font-semibold uppercase tracking-wide">{label}</span>
</div>
{#each items as convo (convo.id)}
<div
class={`w-full flex items-center justify-between gap-1 px-3 py-2 text-sm cursor-pointer transition hover:bg-surface-muted ${convo.id === currentConversationId ? "bg-surface-muted" : ""}`}
class={`group relative w-full cursor-pointer transition border-l-4 ${riskStripeClass(convo)} ${convo.id === currentConversationId ? "bg-primary-light/50" : "hover:bg-surface-muted"}`}
onclick={() => onselect?.(convo.id)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && onselect?.(convo.id)}
>
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-text">{convo.title || "New Conversation"}</div>
<div class="text-xs text-text-muted mt-0.5">
{convo.message_count ? `${convo.message_count} msgs` : ""}
<!-- Status dot -->
<span class={`absolute left-0 top-2 bottom-2 w-1 rounded-r-full ${statusDotClass(convo)}`} aria-hidden="true"></span>
<div class="flex items-start gap-2 px-3 py-2.5 pl-5">
<!-- Icon column -->
<div class="shrink-0 mt-0.5">
{#if convo.has_error}
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-destructive-light text-destructive" title={$t.assistant?.indicator_error || "Содержит ошибки"}>
<Icon name="warning" size={12} />
</span>
{:else if convo.has_tool_calls}
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-warning-light text-warning" title={$t.assistant?.indicator_tools || "Использованы инструменты"}>
<Icon name="activity" size={12} />
</span>
{:else if convo.last_role === "user"}
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-info-light text-info" title={$t.assistant?.indicator_waiting || "Ожидает ответа"}>
<Icon name="user" size={12} />
</span>
{:else}
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-success-light text-success" title={$t.assistant?.indicator_complete || "Завершён"}>
<Icon name="check" size={12} />
</span>
{/if}
</div>
<!-- Text column -->
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text leading-snug">
{convo.title || ($t.assistant?.new_conversation || "Новый диалог")}
</div>
<div class="flex items-center gap-2 mt-0.5">
<!-- Message count badge -->
<span class="inline-flex items-center gap-1 rounded-full bg-surface-muted px-1.5 py-0.5 text-[11px] font-medium text-text-muted leading-none">
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
{convo.message_count ?? 0}
</span>
<!-- Relative time -->
{#if relativeTime(convo.updated_at)}
<span class="text-[11px] text-text-subtle">{relativeTime(convo.updated_at)}</span>
{/if}
</div>
</div>
<!-- Delete button -->
<button
class="shrink-0 mt-0.5 rounded p-1 text-text-subtle opacity-60 transition hover:bg-destructive-light hover:text-destructive focus:opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
onclick={(e) => handleDelete(e, convo.id)}
title={$t.assistant?.delete || "Удалить"}
aria-label={$t.assistant?.delete || "Удалить"}
>
<Icon name="trash" size={12} />
</button>
</div>
<button
class="shrink-0 rounded p-1 text-text-subtle hover:text-destructive hover:bg-destructive-light"
onclick={(e) => handleDelete(e, convo.id)}
title={$t.assistant?.delete || "Delete"}
>
<Icon name="trash" size={12} />
</button>
</div>
{/each}
{/each}
@@ -143,7 +232,7 @@
{#if hasNext}
<div class="p-3">
<Button variant="ghost" size="sm" onclick={() => onloadmore?.()}>
{$t.assistant?.load_more || "Load more"}
{$t.assistant?.load_more || "Загрузить ещё"}
</Button>
</div>
{/if}
@@ -153,11 +242,11 @@
<ConfirmDialog
bind:show={showDeleteConfirm}
title={$t.assistant?.delete_title || "Delete conversation?"}
message={$t.assistant?.delete_confirm || "This conversation will be permanently deleted."}
title={$t.assistant?.delete_title || "Удалить диалог?"}
message={$t.assistant?.delete_confirm || "Диалог будет удалён безвозвратно."}
variant="destructive"
confirmLabel={$t.assistant?.delete || "Delete"}
cancelLabel={$t.common?.cancel || "Cancel"}
confirmLabel={$t.assistant?.delete || "Удалить"}
cancelLabel={$t.common?.cancel || "Отмена"}
onConfirm={() => { if (pendingDeleteId) ondelete?.(pendingDeleteId); }}
onCancel={() => { pendingDeleteId = null; }}
/>

View File

@@ -79,8 +79,63 @@ describe("ConversationList — loaded state", () => {
hasNext: false,
},
});
// Message count is shown as a badge number (no "msgs" text)
expect(container.textContent).toContain("5");
expect(container.textContent).toContain("msgs");
});
// ── New indicators tests ──
it("shows status dot for conversation", () => {
const { container } = render(ConversationList, {
props: {
conversations: [{ id: "c1", title: "Test", updated_at: new Date().toISOString(), message_count: 1, last_role: "assistant", has_tool_calls: false, has_error: false }],
currentConversationId: null,
isLoading: false,
hasNext: false,
},
});
// Status dot should be present (bg-success for completed conversations)
const dots = container.querySelectorAll('[aria-hidden="true"]');
expect(dots.length).toBeGreaterThan(0);
});
it("shows warning icon for conversations with errors", () => {
const { container } = render(ConversationList, {
props: {
conversations: [{ id: "c1", title: "Error Chat", updated_at: new Date().toISOString(), message_count: 2, last_role: "assistant", has_tool_calls: false, has_error: true }],
currentConversationId: null,
isLoading: false,
hasNext: false,
},
});
// Error conversations should show warning icon
const errorText = screen.getByText("Error Chat");
expect(errorText).toBeTruthy();
});
it("shows tool icon for conversations with tool calls", () => {
const { container } = render(ConversationList, {
props: {
conversations: [{ id: "c2", title: "Tool Chat", updated_at: new Date().toISOString(), message_count: 3, last_role: "assistant", has_tool_calls: true, has_error: false }],
currentConversationId: null,
isLoading: false,
hasNext: false,
},
});
const toolText = screen.getByText("Tool Chat");
expect(toolText).toBeTruthy();
});
it("shows relative time for recent conversations", () => {
const { container } = render(ConversationList, {
props: {
conversations: [{ id: "c1", title: "Recent", updated_at: new Date().toISOString(), message_count: 1 }],
currentConversationId: null,
isLoading: false,
hasNext: false,
},
});
// Recent conversation should show relative time (just now or minutes)
expect(container.textContent).toMatch(/только что|\d+ мин/);
});
});
// #endregion TestAgentChat.ConversationList.Render
@@ -207,8 +262,8 @@ describe("ConversationList — delete action", () => {
const deleteBtns = screen.getAllByTitle(/delete/i);
fireEvent.click(deleteBtns[0]);
// ConfirmDialog appears — click "Cancel" button
const cancelBtn = screen.getByText("Cancel");
// ConfirmDialog appears — click the cancel button (fallback "Отмена")
const cancelBtn = screen.getByText("Отмена");
expect(cancelBtn).toBeTruthy();
fireEvent.click(cancelBtn);

View File

@@ -314,6 +314,7 @@
checked={selectedIds.includes(item[keyField])}
onchange={(e) => handleSelectionChange(item[keyField], (e.target as HTMLInputElement).checked)}
class="h-4 w-4 text-primary border-border-strong rounded focus-visible:ring-primary-ring"
aria-label={`Select ${item[keyField]}`}
/>
</td>
{/if}

View File

@@ -101,6 +101,35 @@ describe('DashboardDataGrid', () => {
expect((checkboxes[1] as HTMLInputElement).checked).toBe(true);
});
// ── P1-2 fix: row checkboxes have aria-labels ──
it('renders aria-label on row checkboxes when selectable', () => {
const { container } = render(DashboardDataGrid, {
data: sampleData,
columns: sampleColumns,
selectable: true,
selectedIds: [],
});
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
// Select-all checkbox
expect(checkboxes[0].getAttribute('aria-label')).toBe('Select all');
// Row checkboxes should have descriptive labels
expect(checkboxes[1].getAttribute('aria-label')).toBe('Select 1');
expect(checkboxes[2].getAttribute('aria-label')).toBe('Select 2');
expect(checkboxes[3].getAttribute('aria-label')).toBe('Select 3');
});
it('does NOT render checkboxes when selectable is false', () => {
const { container } = render(DashboardDataGrid, {
data: sampleData,
columns: sampleColumns,
selectable: false,
});
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
expect(checkboxes.length).toBe(0);
});
// @TEST_SCENARIO: sort
it('toggles sort direction on sortable column header click', async () => {
const { container } = render(DashboardDataGrid, {

View File

@@ -101,7 +101,7 @@
if (item.path === "/") {
return {
icon: "home",
tone: "from-sky-100 to-cyan-100 text-sky-700 ring-sky-200",
tone: "from-category-dashboards-from to-category-dashboards-to text-category-dashboards-text ring-category-dashboards-ring",
};
}
@@ -110,38 +110,38 @@
const map = {
dashboards: {
icon: "dashboard",
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
tone: "from-category-dashboards-from to-category-dashboards-to text-category-dashboards-text ring-category-dashboards-ring",
},
datasets: {
icon: "database",
tone: "from-emerald-100 to-emerald-200 text-success ring-success-ring",
tone: "from-category-datasets-from to-category-datasets-to text-category-datasets-text ring-category-datasets-ring",
},
storage: {
icon: "storage",
tone: "from-amber-100 to-amber-200 text-warning ring-warning-ring",
tone: "from-category-migration-from to-category-migration-to text-category-migration-text ring-category-migration-ring",
},
reports: {
icon: "reports",
tone: "from-violet-100 to-fuchsia-100 text-primary ring-primary-ring",
tone: "from-category-reports-from to-category-reports-to text-category-reports-text ring-category-reports-ring",
},
admin: {
icon: "admin",
tone: "from-destructive-light to-destructive-light text-destructive ring-destructive-ring",
tone: "from-category-admin-from to-category-admin-to text-category-admin-text ring-category-admin-ring",
},
settings: {
icon: "settings",
tone: "from-slate-100 to-slate-200 text-text ring-secondary-ring",
tone: "from-category-tools-from to-category-tools-to text-category-tools-text ring-category-tools-ring",
},
git: {
icon: "storage",
tone: "from-orange-100 to-orange-200 text-warning ring-orange-200",
tone: "from-category-git-from to-category-git-to text-category-git-text ring-category-git-ring",
},
};
return (
map[segment] || {
icon: "layers",
tone: "from-slate-100 to-slate-200 text-text-muted ring-secondary-ring",
tone: "from-category-tools-from to-category-tools-to text-category-tools-text ring-category-tools-ring",
}
);
}

View File

@@ -162,5 +162,40 @@ describe("sidebarNavigation", () => {
expect(categoryIds).toContain("git");
expect(categoryIds).toContain("tools");
});
// ── P0-2 fix: semantic design token compliance ──
describe("design token compliance", () => {
const rawColorRE = /\b(from|to|text|ring)-(blue|red|green|yellow|amber|orange|purple|pink|indigo|gray|slate|zinc|neutral|stone|sky|cyan|teal|emerald|lime|violet|fuchsia|rose)-\d{2,3}\b/;
it("all category tone values use semantic tokens, not raw Tailwind colors", () => {
const user = makeUser([{ name: "Admin", permissions: [] }]);
const sections = buildSidebarSections(i18nState, user);
for (const section of sections) {
for (const category of section.categories) {
expect(
rawColorRE.test(category.tone),
`Category "${category.id}" has raw Tailwind color in tone: "${category.tone}"`
).toBe(false);
}
}
});
it("all categories have valid tone strings", () => {
const user = makeUser([{ name: "Admin", permissions: [] }]);
const sections = buildSidebarSections(i18nState, user);
const categories = sections.flatMap((s) => s.categories);
for (const category of categories) {
expect(category.tone).toBeTruthy();
expect(typeof category.tone).toBe("string");
// Every tone should contain gradient classes
expect(category.tone).toMatch(/from-category-/);
expect(category.tone).toMatch(/to-category-/);
expect(category.tone).toMatch(/text-category-/);
expect(category.tone).toMatch(/ring-category-/);
}
});
});
});
// #endregion SidebarNavigationTest:Module

View File

@@ -93,7 +93,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "dashboards",
label: nav.dashboards,
icon: "dashboard",
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
tone: "from-category-dashboards-from to-category-dashboards-to text-category-dashboards-text ring-category-dashboards-ring",
path: "/dashboards",
requiredPermission: "plugin:migration",
requiredAction: "READ",
@@ -106,7 +106,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "datasets",
label: nav.datasets,
icon: "database",
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
tone: "from-category-datasets-from to-category-datasets-to text-category-datasets-text ring-category-datasets-ring",
path: "/datasets",
requiredPermission: "plugin:migration",
requiredAction: "READ",
@@ -119,7 +119,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "migration",
label: nav.migration,
icon: "layers",
tone: "from-orange-100 to-orange-200 text-orange-700 ring-orange-200",
tone: "from-category-migration-from to-category-migration-to text-category-migration-text ring-category-migration-ring",
path: "/migration",
requiredPermission: "plugin:migration",
requiredAction: "READ",
@@ -141,7 +141,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "git",
label: nav.git,
icon: "activity",
tone: "from-purple-100 to-purple-200 text-purple-700 ring-purple-200",
tone: "from-category-git-from to-category-git-to text-category-git-text ring-category-git-ring",
path: "/git",
requiredFeature: "git_integration",
subItems: [
@@ -152,7 +152,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "translation",
label: nav.translation,
icon: "translate",
tone: "from-cyan-100 to-teal-100 text-teal-700 ring-teal-200",
tone: "from-category-translation-from to-category-translation-to text-category-translation-text ring-category-translation-ring",
path: "/translate",
requiredPermission: "translate.job",
requiredAction: "VIEW",
@@ -167,7 +167,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "validation",
label: nav.validation,
icon: "activity",
tone: "from-amber-100 to-yellow-100 text-amber-700 ring-amber-200",
tone: "from-category-validation-from to-category-validation-to text-category-validation-text ring-category-validation-ring",
path: "/validation-tasks",
requiredPermission: "validation.task",
requiredAction: "VIEW",
@@ -181,7 +181,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "reports",
label: nav.reports,
icon: "reports",
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
tone: "from-category-reports-from to-category-reports-to text-category-reports-text ring-category-reports-ring",
path: "/reports",
requiredPermission: "tasks",
requiredAction: "READ",
@@ -201,7 +201,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "tools",
label: nav.tools,
icon: "list",
tone: "from-slate-100 to-slate-200 text-slate-700 ring-slate-200",
tone: "from-category-tools-from to-category-tools-to text-category-tools-text ring-category-tools-ring",
path: "/tools/mapper",
subItems: [
{ label: nav.tools_mapper, path: "/tools/mapper", requiredFeature: "dataset_mapper" },
@@ -214,7 +214,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "admin",
label: nav.admin,
icon: "admin",
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
tone: "from-category-admin-from to-category-admin-to text-category-admin-text ring-category-admin-ring",
path: "/admin/users",
subItems: [
{ label: nav.admin_users, path: "/admin/users", requiredPermission: "admin:users", requiredAction: "READ" },
@@ -227,7 +227,7 @@ export function buildSidebarSections(i18nState: I18nNavState, user: User | null
id: "maintenance",
label: nav.maintenance || "Maintenance",
icon: "activity",
tone: "from-orange-100 to-orange-200 text-orange-700 ring-orange-200",
tone: "from-category-maintenance-from to-category-maintenance-to text-category-maintenance-text ring-category-maintenance-ring",
path: "/maintenance",
requiredPermission: "maintenance:write",
requiredAction: "READ",

View File

@@ -0,0 +1,66 @@
// #region I18nLocalesTests [C:2] [TYPE Module] [SEMANTICS test,i18n,locales,keys]
// @BRIEF Verify i18n locale files have required keys for P0-1 and P1-3 fixes.
// @TEST_INVARIANT: missing_context_hint -> present in dashboard.json (ru + en)
// @TEST_INVARIANT: skip_to_content -> present in common.json (ru + en)
import { describe, it, expect } from "vitest";
import ruDashboard from "../locales/ru/dashboard.json";
import enDashboard from "../locales/en/dashboard.json";
import ruCommon from "../locales/ru/common.json";
import enCommon from "../locales/en/common.json";
describe("i18n locale keys — post-fix verification", () => {
// ── P0-1: missing_context_hint in dashboard.json ──
describe("dashboard.json", () => {
it("ru/dashboard.json has missing_context_hint", () => {
expect(ruDashboard).toHaveProperty("missing_context");
expect(ruDashboard).toHaveProperty("missing_context_hint");
expect(typeof ruDashboard.missing_context_hint).toBe("string");
expect(ruDashboard.missing_context_hint.length).toBeGreaterThan(20);
// Should mention environment selection
expect(ruDashboard.missing_context_hint).toMatch(/окружение/i);
});
it("en/dashboard.json has missing_context_hint", () => {
expect(enDashboard).toHaveProperty("missing_context");
expect(enDashboard).toHaveProperty("missing_context_hint");
expect(typeof enDashboard.missing_context_hint).toBe("string");
expect(enDashboard.missing_context_hint.length).toBeGreaterThan(20);
// Should mention environment selection
expect(enDashboard.missing_context_hint).toMatch(/environment/i);
});
});
// ── P1-3: skip_to_content in common.json ──
describe("common.json", () => {
it("ru/common.json has skip_to_content", () => {
expect(ruCommon).toHaveProperty("skip_to_content");
expect(typeof ruCommon.skip_to_content).toBe("string");
expect(ruCommon.skip_to_content.length).toBeGreaterThan(5);
});
it("en/common.json has skip_to_content", () => {
expect(enCommon).toHaveProperty("skip_to_content");
expect(typeof enCommon.skip_to_content).toBe("string");
expect(enCommon.skip_to_content.length).toBeGreaterThan(5);
});
});
// ── Consistency: matching keys across locales ──
describe("key parity across locales", () => {
it("dashboard.json has same top-level keys in ru and en", () => {
const ruKeys = Object.keys(ruDashboard).sort();
const enKeys = Object.keys(enDashboard).sort();
// Not all keys need to match exactly, but critical ones should
expect(ruKeys).toContain("missing_context_hint");
expect(enKeys).toContain("missing_context_hint");
});
it("common.json has same top-level keys in ru and en", () => {
const ruKeys = Object.keys(ruCommon).sort();
const enKeys = Object.keys(enCommon).sort();
expect(ruKeys).toContain("skip_to_content");
expect(enKeys).toContain("skip_to_content");
});
});
});
// #endregion I18nLocalesTests

View File

@@ -27,8 +27,13 @@
"session_scope_label": "Session",
"focus_target_label": "Focus",
"clear_focus_action": "Clear focus",
"confirmation_card_title": "⚠️ Confirm action",
"confirmation_card_title": "Confirm action",
"confirmation_read_title": "Allow data read?",
"confirmation_write_title": "Confirm operation?",
"confirmation_scope_fallback": "This operation modifies data or starts tasks in the system. Please review the details before confirming.",
"confirmation_read_description": "The agent wants to read data from the system. Review the tool before continuing.",
"confirmation_unknown_description": "The agent is requesting an action without full details. Confirm only if this request is expected.",
"confirmation_missing_checkpoint": "No checkpoint was received for continuation. Denying safely returns the chat to ready state.",
"confirmation_operation_label": "Operation",
"highlight_workspace_action": "Highlight in workspace",
"clarification_banner_title": "Clarification queue is active",
@@ -61,7 +66,9 @@
"confirm_expired": "Confirmation expired",
"confirm_retry": "Retry request",
"confirm": "Confirm",
"confirming": "Confirming…",
"deny": "Deny",
"cancelling": "Cancelling…",
"file_upload": "Attach file",
"file_parse_error": "Could not parse file",
"file_unsupported": "Unsupported format",
@@ -78,6 +85,27 @@
"this_week": "This week",
"search_placeholder": "Search conversations...",
"new_conversation": "New Conversation",
"messages_short": "msgs",
"environment": "Environment",
"active_tools": "Tools",
"quick_dashboards": "Find dashboards",
"quick_dashboards_desc": "Show available resources and open the right object quickly.",
"quick_migration": "Run migration",
"quick_migration_desc": "Prepare a dashboard move between environments.",
"quick_tasks": "Check tasks",
"quick_tasks_desc": "Review active background operations and results.",
"quick_health": "Diagnostics",
"quick_health_desc": "Check system, environments, and failing dashboards.",
"welcome_empty_hint": "Ask a question or choose an operational scenario.",
"welcome_continue_hint": "Continue a conversation or start a new one.",
"phases": {
"ready": "Ready",
"thinking": "Thinking",
"tooling": "Using tool",
"confirming": "Needs confirmation",
"failed": "Failed",
"offline": "Offline"
},
"delete": "Delete",
"retry": "Retry",
"details": "Details",
@@ -85,5 +113,12 @@
"welcome_title": "Ready to work",
"welcome_start_action": "Start",
"welcome_custom_action": "Custom question",
"expand_to_agent": "Expand"
"expand_to_agent": "Expand",
"indicator_error": "Contains errors",
"indicator_tools": "Tools used",
"indicator_waiting": "Awaiting response",
"indicator_complete": "Completed",
"time_just_now": "just now",
"time_min": "m",
"time_hr": "h"
}

View File

@@ -36,5 +36,6 @@
"status": "Status",
"started": "Started",
"finished": "Finished",
"duration": "Duration"
"duration": "Duration",
"skip_to_content": "Skip to content"
}

View File

@@ -83,6 +83,7 @@
"migration_task_failed": "Failed to create migration task",
"backup_task_failed": "Failed to create backup task",
"missing_context": "Missing dashboard ID or environment ID",
"missing_context_hint": "No environment selected. Choose an environment from the top bar and reload the page.",
"load_detail_failed": "Failed to load dashboard details",
"no_charts": "No charts found for this dashboard.",
"no_datasets": "No datasets found for this dashboard.",

View File

@@ -27,8 +27,13 @@
"session_scope_label": "Сессия",
"focus_target_label": "Фокус",
"clear_focus_action": "Сбросить фокус",
"confirmation_card_title": "⚠️ Подтвердите действие",
"confirmation_card_title": "Подтвердите действие",
"confirmation_read_title": "Разрешить чтение данных?",
"confirmation_write_title": "Подтвердить операцию?",
"confirmation_scope_fallback": "Эта операция изменяет данные или запускает задачи в системе. Пожалуйста, проверьте детали перед подтверждением.",
"confirmation_read_description": "Агент хочет получить данные из системы. Проверьте инструмент перед продолжением.",
"confirmation_unknown_description": "Агент запрашивает действие без полной детализации. Подтверждайте только если запрос ожидаемый.",
"confirmation_missing_checkpoint": "Не получен checkpoint для продолжения. Отклонение безопасно вернет чат в готовое состояние.",
"confirmation_operation_label": "Операция",
"highlight_workspace_action": "Подсветить в workspace",
"clarification_banner_title": "Очередь уточнений активна",
@@ -61,7 +66,9 @@
"confirm_expired": "Время подтверждения истекло",
"confirm_retry": "Повторить запрос",
"confirm": "Подтвердить",
"confirming": "Подтверждение…",
"deny": "Отклонить",
"cancelling": "Отмена…",
"file_upload": "Прикрепить файл",
"file_parse_error": "Не удалось обработать файл",
"file_unsupported": "Неподдерживаемый формат",
@@ -78,6 +85,27 @@
"this_week": "На этой неделе",
"search_placeholder": "Поиск диалогов...",
"new_conversation": "Новый диалог",
"messages_short": "сообщ.",
"environment": "Окружение",
"active_tools": "Инструменты",
"quick_dashboards": "Найти дашборды",
"quick_dashboards_desc": "Показать доступные ресурсы и быстро открыть нужный объект.",
"quick_migration": "Запустить миграцию",
"quick_migration_desc": "Подготовить перенос дашборда между окружениями.",
"quick_tasks": "Проверить задачи",
"quick_tasks_desc": "Посмотреть активные фоновые операции и их результат.",
"quick_health": "Диагностика",
"quick_health_desc": "Проверить состояние системы, окружений и проблемных дашбордов.",
"welcome_empty_hint": "Задайте вопрос или выберите рабочий сценарий.",
"welcome_continue_hint": "Продолжите диалог или начните новый.",
"phases": {
"ready": "Готов",
"thinking": "Думает",
"tooling": "Выполняет инструмент",
"confirming": "Ждет подтверждения",
"failed": "Ошибка",
"offline": "Нет соединения"
},
"delete": "Удалить",
"retry": "Повторить",
"details": "Детали",
@@ -85,5 +113,12 @@
"welcome_title": "Готов к работе",
"welcome_start_action": "Начать",
"welcome_custom_action": "Свой вопрос",
"expand_to_agent": "Развернуть"
"expand_to_agent": "Развернуть",
"indicator_error": "Содержит ошибки",
"indicator_tools": "Использованы инструменты",
"indicator_waiting": "Ожидает ответа",
"indicator_complete": "Завершён",
"time_just_now": "только что",
"time_min": "мин",
"time_hr": "ч"
}

View File

@@ -36,5 +36,6 @@
"status": "Статус",
"started": "Запущено",
"finished": "Завершено",
"duration": "Длительность"
"duration": "Длительность",
"skip_to_content": "Перейти к содержимому"
}

View File

@@ -82,6 +82,7 @@
"migration_task_failed": "Не удалось создать задачу миграции",
"backup_task_failed": "Не удалось создать задачу бэкапа",
"missing_context": "Отсутствует ID дашборда или окружения",
"missing_context_hint": "Не указано окружение. Выберите окружение в верхней панели и обновите страницу.",
"load_detail_failed": "Не удалось загрузить детали дашборда",
"no_charts": "Для этого дашборда чарты не найдены.",
"no_datasets": "Для этого дашборда датасеты не найдены.",

View File

@@ -93,6 +93,8 @@ export class AgentChatModel {
pendingRiskLevel: string | null = $state(null);
/** Set to true when user clicks Stop — prevents false 'agent unavailable' fallback. */
_userCancelled: boolean = $state(false);
/** Populated by file_uploaded metadata — attached to user message on stream end. */
_pendingAttachment: { file_name: string; file_path: string; file_size: number } | null = $state(null);
userId: string = "";
userJwt: string = "";
envId: string = "";
@@ -302,8 +304,13 @@ export class AgentChatModel {
normalizeConversationTitle(rawTitle: unknown): string {
const title = String(rawTitle ?? "")
// Strip file upload markers (fallback for old conversations)
.replace(/\n--- Uploaded file content ---[\s\S]*$/i, "")
// Strip pre-fetched data blocks
.replace(/\s*\[PRE-FETCHED DATA[\s\S]*$/i, "")
// Strip trailing "Available dashboards..." suffix
.replace(/\s+Available(?:\s+\S+)*$/i, "")
// Collapse whitespace
.replace(/\s+/g, " ")
.trim();
return title || "Новый диалог";

View File

@@ -27,6 +27,7 @@ import { log } from '$lib/cot-logger';
import { GitStatusModel } from '$lib/models/GitStatusModel.svelte.ts';
import { addToast } from '$lib/toasts.svelte.js';
import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js';
import { environmentContextStore } from '$lib/stores/environmentContext.svelte.js';
import { getT } from '$lib/i18n/index.svelte.js';
import { SvelteURLSearchParams, SvelteDate } from "svelte/reactivity";
@@ -130,8 +131,18 @@ export class DashboardDetailModel {
}
async loadDashboardDetail(): Promise<void> {
// Auto-populate envId from context store if URL did not provide one
if (!this.envId) {
const storedEnv = environmentContextStore.value?.selectedEnvId;
if (storedEnv) {
this.envId = storedEnv;
}
}
if (!this.dashboardRef || !this.envId) {
this.error = getT()?.dashboard?.missing_context;
const t = getT();
this.error = t?.dashboard?.missing_context_hint
|| t?.dashboard?.missing_context
|| 'Missing dashboard ID or environment. Select an environment from the top bar.';
this.screenState = 'error';
return;
}

View File

@@ -24,6 +24,11 @@ vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({
openDrawerForTaskIfPreferred: vi.fn(),
openDrawerForTask: vi.fn(),
}));
vi.mock("$lib/stores/environmentContext.svelte.js", () => ({
environmentContextStore: {
value: { selectedEnvId: "" },
},
}));
vi.mock("$lib/routes.js", () => ({
ROUTES: {
dashboards: { list: vi.fn(() => "/dashboards"), detail: vi.fn(() => "/dashboards/1") },
@@ -34,6 +39,7 @@ vi.mock("$lib/i18n/index.svelte.js", () => ({
getT: () => ({
dashboard: {
missing_context: "Missing context",
missing_context_hint: "No environment selected. Choose an environment from the top bar and reload the page.",
load_detail_failed: "Failed",
thumbnail_generating: "Generating...",
thumbnail_failed: "Thumbnail failed",
@@ -175,6 +181,61 @@ describe("DashboardDetailModel — L1 invariants (no render)", () => {
expect(model.screenState).toBe("error");
});
// ── P0-1 fix: auto-populate envId from environmentContextStore ──
it("auto-populates envId from store when URL param is missing", async () => {
const { environmentContextStore } = await import("$lib/stores/environmentContext.svelte.js");
(environmentContextStore as any).value = { selectedEnvId: "ss-dev" };
vi.mocked(api.getDashboardDetail).mockResolvedValue({ id: 1, title: "Test" });
model.dashboardRef = "test-dash";
model.envId = ""; // missing from URL
await model.loadDashboardDetail();
expect(model.screenState).toBe("loaded");
expect(model.envId).toBe("ss-dev");
expect(api.getDashboardDetail).toHaveBeenCalledWith("ss-dev", "test-dash");
});
it("does NOT override envId from URL when already present", async () => {
const { environmentContextStore } = await import("$lib/stores/environmentContext.svelte.js");
(environmentContextStore as any).value = { selectedEnvId: "ss-dev" };
vi.mocked(api.getDashboardDetail).mockResolvedValue({ id: 2, title: "Test2" });
model.dashboardRef = "test-dash";
model.envId = "ss-prod"; // explicitly set from URL
await model.loadDashboardDetail();
// envId from URL should take priority over store fallback
expect(api.getDashboardDetail).toHaveBeenCalledWith("ss-prod", "test-dash");
});
it("shows helpful error hint when no envId from URL or store", async () => {
const { environmentContextStore } = await import("$lib/stores/environmentContext.svelte.js");
(environmentContextStore as any).value = { selectedEnvId: "" };
model.dashboardRef = "test-dash";
model.envId = "";
await model.loadDashboardDetail();
expect(model.screenState).toBe("error");
// Error should contain the recovery hint from missing_context_hint
expect(model.error).toBeTruthy();
expect(model.error).toEqual(expect.stringContaining("environment"));
});
it("handles undefined store value gracefully", async () => {
const { environmentContextStore } = await import("$lib/stores/environmentContext.svelte.js");
(environmentContextStore as any).value = undefined;
vi.mocked(api.getDashboardDetail).mockResolvedValue({ id: 3, title: "Test3" });
model.dashboardRef = "test-dash";
model.envId = "ss-prod"; // envId from URL
await model.loadDashboardDetail();
// Should still work with explicit envId
expect(model.screenState).toBe("loaded");
});
});
// ═══════════════════════════════════════════════════════════════

View File

@@ -48,6 +48,11 @@
<Toast />
<!-- Skip-to-content link for keyboard accessibility -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:bg-primary focus:px-4 focus:py-2 focus:text-white focus:shadow-lg focus:outline-none">
{$t.common?.skip_to_content || 'Skip to content'}
</a>
<!-- Global persistent translation run progress indicator -->
<TranslationRunGlobalIndicator />
@@ -76,7 +81,7 @@
</div>
<!-- Page content -->
<div class="flex-grow px-4 pb-6 pt-2 md:px-6">
<div id="main-content" class="flex-grow px-4 pb-6 pt-2 md:px-6" tabindex="-1">
{@render children?.()}
</div>

View File

@@ -95,6 +95,19 @@ export default {
git: '#fb923c',
system: '#38bdf8',
},
// ── Category gradient tokens (sidebar/breadcrumb icons) ──
category: {
dashboards: { from: '#e0f2fe', to: '#bae6fd', text: '#0369a1', ring: '#7dd3fc' }, // sky
datasets: { from: '#dcfce7', to: '#bbf7d0', text: '#15803d', ring: '#86efac' }, // emerald→success
migration: { from: '#ffedd5', to: '#fed7aa', text: '#c2410c', ring: '#fdba74' }, // orange→warning
git: { from: '#f3e8ff', to: '#e9d5ff', text: '#7e22ce', ring: '#d8b4fe' }, // purple
translation: { from: '#cffafe', to: '#99f6e4', text: '#0f766e', ring: '#5eead4' }, // cyan/teal→info
validation: { from: '#fef3c7', to: '#fde68a', text: '#a16207', ring: '#fcd34d' }, // amber
reports: { from: '#ede9fe', to: '#ddd6fe', text: '#6d28d9', ring: '#c4b5fd' }, // violet
tools: { from: '#f1f5f9', to: '#e2e8f0', text: '#334155', ring: '#94a3b8' }, // slate→surface
admin: { from: '#ffe4e6', to: '#fecdd3', text: '#be123c', ring: '#fda4af' }, // rose→destructive
maintenance: { from: '#ffedd5', to: '#fed7aa', text: '#c2410c', ring: '#fdba74' }, // orange→warning
},
},
width: {
sidebar: '240px',