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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
214
backend/src/agent/_confirmation.py
Normal file
214
backend/src/agent/_confirmation.py
Normal 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
|
||||
376
backend/src/agent/_persistence.py
Normal file
376
backend/src/agent/_persistence.py
Normal 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
|
||||
190
backend/src/agent/_tool_resolver.py
Normal file
190
backend/src/agent/_tool_resolver.py
Normal 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
|
||||
@@ -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__":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
129
backend/tests/agent/test_title_cleaning.py
Normal file
129
backend/tests/agent/test_title_cleaning.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
203
frontend/src/lib/components/assistant/ConfirmationCard.svelte
Normal file
203
frontend/src/lib/components/assistant/ConfirmationCard.svelte
Normal 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">
|
||||
↵ Подтвердить · ⎋ Отклонить
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- #endregion AgentChat.ConfirmationCard -->
|
||||
@@ -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; }}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
66
frontend/src/lib/i18n/__tests__/locales.test.ts
Normal file
66
frontend/src/lib/i18n/__tests__/locales.test.ts
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,6 @@
|
||||
"status": "Status",
|
||||
"started": "Started",
|
||||
"finished": "Finished",
|
||||
"duration": "Duration"
|
||||
"duration": "Duration",
|
||||
"skip_to_content": "Skip to content"
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "ч"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,6 @@
|
||||
"status": "Статус",
|
||||
"started": "Запущено",
|
||||
"finished": "Завершено",
|
||||
"duration": "Длительность"
|
||||
"duration": "Длительность",
|
||||
"skip_to_content": "Перейти к содержимому"
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"migration_task_failed": "Не удалось создать задачу миграции",
|
||||
"backup_task_failed": "Не удалось создать задачу бэкапа",
|
||||
"missing_context": "Отсутствует ID дашборда или окружения",
|
||||
"missing_context_hint": "Не указано окружение. Выберите окружение в верхней панели и обновите страницу.",
|
||||
"load_detail_failed": "Не удалось загрузить детали дашборда",
|
||||
"no_charts": "Для этого дашборда чарты не найдены.",
|
||||
"no_datasets": "Для этого дашборда датасеты не найдены.",
|
||||
|
||||
@@ -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 || "Новый диалог";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user