fix(agent): critical agent chat bugs — backend startup & frontend streaming state
Backend (tools.py): - Add Python docstrings to all 17 @tool functions (LangChain ValueError) - Add @INVARIANT ADR: docstring requirement documented in module header - Fix 2 f-string escaped-quote syntax errors (Python 3.13) Frontend — compile errors (+page.svelte): - Fix mismatched <button>/</Button> tags - Fix missing Button import for mobile sidebar close Frontend — streaming state loss on conversation switch (AgentChatModel): - Add _commitStreamingPartial() helper — saves in-progress text before cancelling - selectConversation() commits partial text to OLD conversation before switching - createConversation() commits partial text before clearing state - loadHistory() sets _userCancelled=true to suppress fallback messages Frontend — ConnectionManager: - Pass error reason through onDisconnectedPermanent callback → model.error Frontend — null safety: - Guard _client.submit() calls against null _client in _sendNow and resumeConfirm
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
# backend/src/agent/tools.py
|
||||
# #region AgentChat.Tools [C:4] [TYPE Module] [SEMANTICS agent-chat,tools,langchain]
|
||||
# @DEFGROUP AgentChat Native LangChain @tool functions.
|
||||
# @REJECTED StructuredTool wrapping — native @tool is the single source of truth.
|
||||
# @REJECTED StructuredTool wrapping — native @tool is the single source of truth.
|
||||
# @defgroup AgentChat Native LangChain @tool functions.
|
||||
# @RATIONALE LangChain @tool decorator chosen over direct FastAPI calls for LangGraph compatibility — tools are auto-registered in the agent's tool-calling loop.
|
||||
# @REJECTED StructuredTool wrapping — native @tool is the single source of truth.
|
||||
# @INVARIANT Every @tool function MUST have a Python docstring (triple-quoted string immediately after the signature). LangChain raises ValueError("Function must have a docstring if description not provided.") when args_schema is provided without a description param AND the function lacks a docstring. This is a runtime blocker — the entire Gradio agent fails to import when ANY single tool violates this invariant. The GRACE @BRIEF comment is NOT a substitute — it's structural metadata invisible to the Python runtime. Rule: BEFORE decorating with @tool(args_schema=...), ensure `"""One-line description."""` is present on the next line.
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -18,14 +18,12 @@ from src.agent.context import get_service_jwt, get_user_jwt
|
||||
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000")
|
||||
TOOL_RESPONSE_LIMIT = 4000
|
||||
|
||||
# ── Internal helpers ─────────────────────────────────────────────
|
||||
|
||||
# #region AgentChat.Tools.DualAuthHeaders [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,auth,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Build dual-identity auth headers for tool→FastAPI calls per FR-007/FR-019.
|
||||
def _dual_auth_headers() -> dict[str, str]:
|
||||
"""Build dual-identity auth headers for tool→FastAPI calls per FR-007/FR-019.
|
||||
|
||||
Authorization: Bearer {service_jwt} — authenticates the agent container.
|
||||
X-User-JWT: {user_jwt} — authorizes the operation as the end user.
|
||||
Falls back to user JWT in Authorization if service JWT unavailable.
|
||||
"""
|
||||
user_jwt = get_user_jwt() or ""
|
||||
svc_jwt = get_service_jwt() or os.getenv("SERVICE_JWT", "")
|
||||
headers = {}
|
||||
@@ -36,14 +34,56 @@ def _dual_auth_headers() -> dict[str, str]:
|
||||
elif user_jwt:
|
||||
headers["Authorization"] = f"Bearer {user_jwt}"
|
||||
return headers
|
||||
# #endregion AgentChat.Tools.DualAuthHeaders
|
||||
|
||||
|
||||
# #region AgentChat.Tools.TrimResponse [C:1] [TYPE Function] [SEMANTICS agent-chat,tools,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Truncate response text to TOOL_RESPONSE_LIMIT characters.
|
||||
def _trim_response(text: str, limit: int = TOOL_RESPONSE_LIMIT) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[:limit]}\n... response truncated ..."
|
||||
# #endregion AgentChat.Tools.TrimResponse
|
||||
|
||||
|
||||
_SENSITIVE_FIELD_FRAGMENTS = ("password", "secret", "token", "api_key", "apikey")
|
||||
|
||||
|
||||
# #region AgentChat.Tools.RedactSensitive [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,security,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Recursively redact sensitive fields (passwords, tokens, keys) from dict/list values.
|
||||
def _redact_sensitive_fields(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [_redact_sensitive_fields(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
redacted: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
lowered = str(key).lower()
|
||||
if any(fragment in lowered for fragment in _SENSITIVE_FIELD_FRAGMENTS):
|
||||
redacted[key] = "[redacted]"
|
||||
else:
|
||||
redacted[key] = _redact_sensitive_fields(item)
|
||||
return redacted
|
||||
return value
|
||||
# #endregion AgentChat.Tools.RedactSensitive
|
||||
|
||||
|
||||
# #region AgentChat.Tools.SafeJsonText [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,security,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Parse JSON text and redact sensitive fields before re-serializing.
|
||||
def _safe_json_text(text: str) -> str:
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return text
|
||||
return json.dumps(_redact_sensitive_fields(data), ensure_ascii=False)
|
||||
# #endregion AgentChat.Tools.SafeJsonText
|
||||
|
||||
|
||||
# #region AgentChat.Tools.HttpGet [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,http,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Async HTTP GET to FastAPI with dual-auth headers.
|
||||
async def _get(path: str, params: dict[str, Any] | None = None) -> httpx.Response:
|
||||
async with httpx.AsyncClient() as client:
|
||||
return await client.get(
|
||||
@@ -51,8 +91,12 @@ async def _get(path: str, params: dict[str, Any] | None = None) -> httpx.Respons
|
||||
params=params,
|
||||
headers=_dual_auth_headers(),
|
||||
)
|
||||
# #endregion AgentChat.Tools.HttpGet
|
||||
|
||||
|
||||
# #region AgentChat.Tools.HttpPost [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,http,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Async HTTP POST to FastAPI with dual-auth headers.
|
||||
async def _post(
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
@@ -65,15 +109,23 @@ async def _post(
|
||||
params=params,
|
||||
headers=_dual_auth_headers(),
|
||||
)
|
||||
# #endregion AgentChat.Tools.HttpPost
|
||||
|
||||
|
||||
# #region AgentChat.Tools.ApiResult [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,http,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Extract result text from HTTP response, trimming to limit.
|
||||
def _api_result(resp: httpx.Response, ok_statuses: set[int] | None = None) -> str:
|
||||
ok_statuses = ok_statuses or {200, 201, 202}
|
||||
if resp.status_code not in ok_statuses:
|
||||
return f"Error {resp.status_code}: {resp.text}"
|
||||
return _trim_response(resp.text)
|
||||
# #endregion AgentChat.Tools.ApiResult
|
||||
|
||||
|
||||
# #region AgentChat.Tools.ResolveProvider [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,llm,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Resolve active LLM provider ID — explicit or first active from /api/llm/providers.
|
||||
async def _resolve_active_provider_id(provider_id: str | None = None) -> str | None:
|
||||
if provider_id:
|
||||
return provider_id
|
||||
@@ -90,8 +142,12 @@ async def _resolve_active_provider_id(provider_id: str | None = None) -> str | N
|
||||
if providers:
|
||||
return str(providers[0].get("id"))
|
||||
return None
|
||||
# #endregion AgentChat.Tools.ResolveProvider
|
||||
|
||||
|
||||
# #region AgentChat.Tools.DashboardIds [C:1] [TYPE Function] [SEMANTICS agent-chat,tools,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Merge single dashboard_id + list of dashboard_ids into deduplicated int list.
|
||||
def _dashboard_ids(dashboard_id: int | str | None, dashboard_ids: list[int] | list[str] | None) -> list[int]:
|
||||
values: list[int] = []
|
||||
for raw in dashboard_ids or []:
|
||||
@@ -99,25 +155,29 @@ def _dashboard_ids(dashboard_id: int | str | None, dashboard_ids: list[int] | li
|
||||
if dashboard_id is not None and str(dashboard_id).strip():
|
||||
values.append(int(dashboard_id))
|
||||
return list(dict.fromkeys(values))
|
||||
# #endregion AgentChat.Tools.DashboardIds
|
||||
|
||||
|
||||
# ── Tool: search_dashboards ──
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Tool definitions — each wrapped in a #region contract
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region AgentChat.Tools.SearchDashboards.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,dashboard]
|
||||
class SearchDashboardsInput(BaseModel):
|
||||
query: str = Field(description="Search query for dashboard name")
|
||||
env_id: str | None = Field(default=None, description="Environment ID (e.g. 'prod', 'ss-dev')")
|
||||
# #endregion AgentChat.Tools.SearchDashboards.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.SearchDashboards [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,search,dashboard]
|
||||
# @ingroup AgentChat
|
||||
# @PRE User authenticated via dual-identity JWT
|
||||
# @POST Returns JSON string result from FastAPI
|
||||
# @SIDE_EFFECT HTTP call to FastAPI backend
|
||||
|
||||
# @BRIEF Search and list dashboards by name, with optional environment filter.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns formatted dashboard list string.
|
||||
# @SIDE_EFFECT HTTP GET to FastAPI /api/dashboards.
|
||||
@tool(args_schema=SearchDashboardsInput)
|
||||
async def search_dashboards(query: str, env_id: str | None = None) -> str:
|
||||
"""Search and list dashboards by name, with optional environment filter.
|
||||
Pass env_id like 'prod', 'ss-dev', or 'ss-preprod' to filter by environment.
|
||||
Returns a formatted list of dashboards with their details.
|
||||
"""
|
||||
"""Search and list dashboards by name, with optional environment filter."""
|
||||
params = {"q": query, "env_id": env_id or ""}
|
||||
resp = await _get("/api/dashboards", params=params)
|
||||
if resp.status_code != 200:
|
||||
@@ -142,81 +202,115 @@ async def search_dashboards(query: str, env_id: str | None = None) -> str:
|
||||
return "\n".join(lines)
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
return f"Could not parse dashboards response: {e}"
|
||||
# #endregion AgentChat.Tools.SearchDashboards
|
||||
|
||||
|
||||
# ── Tool: get_health_summary ──
|
||||
# #region AgentChat.Tools.HealthSummary.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,health]
|
||||
class HealthSummaryInput(BaseModel):
|
||||
env_id: str | None = Field(default=None, description="Environment ID (e.g. 'prod', 'ss-dev')")
|
||||
# #endregion AgentChat.Tools.HealthSummary.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.HealthSummary [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,health,summary]
|
||||
# @ingroup AgentChat
|
||||
# @PRE User authenticated via dual-identity JWT
|
||||
# @POST Returns JSON string result from FastAPI
|
||||
# @SIDE_EFFECT HTTP call to FastAPI backend
|
||||
# @BRIEF Get system health summary — dashboard validation status, recent failures.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns health summary text (trimmed to 2000 chars).
|
||||
# @SIDE_EFFECT HTTP GET to FastAPI /api/health/summary.
|
||||
@tool(args_schema=HealthSummaryInput)
|
||||
async def get_health_summary(env_id: str | None = None) -> str:
|
||||
"""Get system health summary — dashboard validation status, recent failures.
|
||||
Pass env_id like 'prod', 'ss-dev', or 'ss-preprod' to filter by environment.
|
||||
"""
|
||||
"""Get system health summary — dashboard validation status, recent failures."""
|
||||
resp = await _get("/api/health/summary", params={"environment_id": env_id or ""})
|
||||
if resp.status_code != 200:
|
||||
return f"Health check failed: HTTP {resp.status_code}"
|
||||
return _trim_response(resp.text, 2000)
|
||||
# #endregion AgentChat.Tools.HealthSummary
|
||||
|
||||
|
||||
# ── Tool: list_environments ──
|
||||
# #region AgentChat.Tools.ListEnvironments [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,environment,list]
|
||||
# @ingroup AgentChat
|
||||
# @PRE User authenticated via dual-identity JWT
|
||||
# @POST Returns JSON string result from FastAPI
|
||||
# @SIDE_EFFECT HTTP call to FastAPI backend
|
||||
# @BRIEF List configured deployment environments.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns JSON string of environments (sensitive fields redacted).
|
||||
# @SIDE_EFFECT HTTP GET to FastAPI /api/settings/environments.
|
||||
@tool
|
||||
async def list_environments() -> str:
|
||||
"""List configured deployment environments."""
|
||||
resp = await _get("/api/settings/environments")
|
||||
return _api_result(resp)
|
||||
result = _api_result(resp)
|
||||
return _trim_response(_safe_json_text(result))
|
||||
# #endregion AgentChat.Tools.ListEnvironments
|
||||
|
||||
|
||||
# ── Tool: get_task_status ──
|
||||
# #region AgentChat.Tools.TaskStatus [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,task,status]
|
||||
# @ingroup AgentChat
|
||||
# @PRE User authenticated via dual-identity JWT
|
||||
# @POST Returns JSON string result from FastAPI
|
||||
# @SIDE_EFFECT HTTP call to FastAPI backend
|
||||
# @BRIEF Check the status of a background task by its task_id.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns task status text from FastAPI.
|
||||
# @SIDE_EFFECT HTTP GET to FastAPI /api/tasks/{task_id}.
|
||||
@tool
|
||||
async def get_task_status(task_id: str) -> str:
|
||||
"""Check the status of a background task by its task_id."""
|
||||
resp = await _get(f"/api/tasks/{task_id}")
|
||||
return _api_result(resp)
|
||||
# #endregion AgentChat.Tools.TaskStatus
|
||||
|
||||
|
||||
# #region AgentChat.Tools.ShowCapabilities [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,capabilities,introspection]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Show all tools available to the agent chat — names + descriptions.
|
||||
@tool
|
||||
async def show_capabilities() -> str:
|
||||
"""Show all tools available to the agent chat."""
|
||||
"""Show all tools available to the agent chat — names + descriptions."""
|
||||
lines = ["Available LangChain tools:"]
|
||||
for tool_obj in get_all_tools():
|
||||
lines.append(f"- {tool_obj.name}: {tool_obj.description or ''}")
|
||||
return "\n".join(lines)
|
||||
# #endregion AgentChat.Tools.ShowCapabilities
|
||||
|
||||
|
||||
# #region AgentChat.Tools.ListLlmProviders [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,llm,providers]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF List configured LLM providers.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns provider list text.
|
||||
# @SIDE_EFFECT HTTP GET to FastAPI /api/llm/providers.
|
||||
@tool
|
||||
async def list_llm_providers() -> str:
|
||||
"""List configured LLM providers."""
|
||||
resp = await _get("/api/llm/providers")
|
||||
return _api_result(resp)
|
||||
# #endregion AgentChat.Tools.ListLlmProviders
|
||||
|
||||
|
||||
# #region AgentChat.Tools.LlmStatus [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,llm,status]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Check whether the LLM runtime is configured and usable.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns LLM status text.
|
||||
# @SIDE_EFFECT HTTP GET to FastAPI /api/llm/status.
|
||||
@tool
|
||||
async def get_llm_status() -> str:
|
||||
"""Check whether the LLM runtime is configured and usable."""
|
||||
resp = await _get("/api/llm/status")
|
||||
return _api_result(resp)
|
||||
# #endregion AgentChat.Tools.LlmStatus
|
||||
|
||||
|
||||
# #region AgentChat.Tools.RunBackup.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,backup]
|
||||
class RunBackupInput(BaseModel):
|
||||
environment_id: str = Field(description="Environment ID for the backup")
|
||||
dashboard_id: int | None = Field(default=None, description="Optional single dashboard ID")
|
||||
dashboard_ids: list[int] | None = Field(default=None, description="Optional list of dashboard IDs")
|
||||
# #endregion AgentChat.Tools.RunBackup.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.RunBackup [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,backup,operation]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Run a Superset backup for an environment, optionally scoped to dashboard IDs.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns task creation result (201).
|
||||
# @SIDE_EFFECT HTTP POST to FastAPI /api/tasks (superset-backup plugin).
|
||||
@tool(args_schema=RunBackupInput)
|
||||
async def run_backup(
|
||||
environment_id: str,
|
||||
@@ -230,16 +324,25 @@ async def run_backup(
|
||||
params["dashboard_ids"] = ids
|
||||
resp = await _post("/api/tasks", {"plugin_id": "superset-backup", "params": params})
|
||||
return _api_result(resp, {201})
|
||||
# #endregion AgentChat.Tools.RunBackup
|
||||
|
||||
|
||||
# #region AgentChat.Tools.ExecuteMigration.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,migration]
|
||||
class ExecuteMigrationInput(BaseModel):
|
||||
source_env_id: str = Field(description="Source environment ID")
|
||||
target_env_id: str = Field(description="Target environment ID")
|
||||
selected_ids: list[int] = Field(description="Dashboard IDs to migrate")
|
||||
replace_db_config: bool = Field(default=False, description="Replace DB configuration during migration")
|
||||
fix_cross_filters: bool = Field(default=True, description="Fix cross filters during migration")
|
||||
# #endregion AgentChat.Tools.ExecuteMigration.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.ExecuteMigration [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,migration,operation]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Execute dashboard migration between two environments.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns migration result text.
|
||||
# @SIDE_EFFECT HTTP POST to FastAPI /api/migration/execute.
|
||||
@tool(args_schema=ExecuteMigrationInput)
|
||||
async def execute_migration(
|
||||
source_env_id: str,
|
||||
@@ -258,18 +361,31 @@ async def execute_migration(
|
||||
}
|
||||
resp = await _post("/api/migration/execute", payload)
|
||||
return _api_result(resp)
|
||||
# #endregion AgentChat.Tools.ExecuteMigration
|
||||
|
||||
|
||||
# ── Git tools: shared input schema ──
|
||||
|
||||
# #region AgentChat.Tools.GitDashboardInput [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,git]
|
||||
class GitDashboardInput(BaseModel):
|
||||
dashboard_ref: str = Field(description="Dashboard ID, slug, or title resolvable by the Git API")
|
||||
env_id: str | None = Field(default=None, description="Optional environment ID used to resolve dashboard_ref")
|
||||
# #endregion AgentChat.Tools.GitDashboardInput
|
||||
|
||||
|
||||
# #region AgentChat.Tools.CreateBranchInput [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,git,branch]
|
||||
class CreateBranchInput(GitDashboardInput):
|
||||
branch_name: str = Field(description="New branch name")
|
||||
from_branch: str = Field(default="dev", description="Source branch")
|
||||
# #endregion AgentChat.Tools.CreateBranchInput
|
||||
|
||||
|
||||
# #region AgentChat.Tools.CreateBranch [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,git,branch]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Create a branch in a dashboard Git repository.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns branch creation result.
|
||||
# @SIDE_EFFECT HTTP POST to FastAPI /api/git/repositories/{ref}/branches.
|
||||
@tool(args_schema=CreateBranchInput)
|
||||
async def create_branch(
|
||||
dashboard_ref: str,
|
||||
@@ -284,13 +400,22 @@ async def create_branch(
|
||||
params={"env_id": env_id} if env_id else None,
|
||||
)
|
||||
return _api_result(resp)
|
||||
# #endregion AgentChat.Tools.CreateBranch
|
||||
|
||||
|
||||
# #region AgentChat.Tools.CommitChanges.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,git,commit]
|
||||
class CommitChangesInput(GitDashboardInput):
|
||||
message: str = Field(description="Commit message")
|
||||
files: list[str] = Field(default_factory=list, description="Files to stage and commit; empty means backend default")
|
||||
# #endregion AgentChat.Tools.CommitChanges.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.CommitChanges [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,git,commit]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Stage and commit changes in a dashboard Git repository.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns commit result.
|
||||
# @SIDE_EFFECT HTTP POST to FastAPI /api/git/repositories/{ref}/commit.
|
||||
@tool(args_schema=CommitChangesInput)
|
||||
async def commit_changes(
|
||||
dashboard_ref: str,
|
||||
@@ -305,12 +430,21 @@ async def commit_changes(
|
||||
params={"env_id": env_id} if env_id else None,
|
||||
)
|
||||
return _api_result(resp)
|
||||
# #endregion AgentChat.Tools.CommitChanges
|
||||
|
||||
|
||||
# #region AgentChat.Tools.DeployDashboard.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,git,deploy]
|
||||
class DeployDashboardInput(GitDashboardInput):
|
||||
environment_id: str = Field(description="Target deployment environment ID")
|
||||
# #endregion AgentChat.Tools.DeployDashboard.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.DeployDashboard [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,git,deploy]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Deploy a dashboard from Git to a target environment.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns deployment result.
|
||||
# @SIDE_EFFECT HTTP POST to FastAPI /api/git/repositories/{ref}/deploy.
|
||||
@tool(args_schema=DeployDashboardInput)
|
||||
async def deploy_dashboard(
|
||||
dashboard_ref: str,
|
||||
@@ -324,14 +458,23 @@ async def deploy_dashboard(
|
||||
params={"env_id": env_id} if env_id else None,
|
||||
)
|
||||
return _api_result(resp)
|
||||
# #endregion AgentChat.Tools.DeployDashboard
|
||||
|
||||
|
||||
# #region AgentChat.Tools.LlmDocumentation.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,llm,documentation]
|
||||
class RunLlmDocumentationInput(BaseModel):
|
||||
dataset_id: str = Field(description="Dataset ID to document")
|
||||
environment_id: str = Field(description="Environment ID")
|
||||
provider_id: str | None = Field(default=None, description="Optional LLM provider ID; active provider is used if omitted")
|
||||
# #endregion AgentChat.Tools.LlmDocumentation.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.LlmDocumentation [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,llm,documentation]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Generate dataset documentation via the LLM documentation task.
|
||||
# @PRE User authenticated via dual-identity JWT. LLM provider configured.
|
||||
# @POST Returns task creation result (201).
|
||||
# @SIDE_EFFECT HTTP POST to FastAPI /api/tasks (llm_documentation plugin).
|
||||
@tool(args_schema=RunLlmDocumentationInput)
|
||||
async def run_llm_documentation(
|
||||
dataset_id: str,
|
||||
@@ -348,8 +491,10 @@ async def run_llm_documentation(
|
||||
params["provider_id"] = resolved_provider
|
||||
resp = await _post("/api/tasks", {"plugin_id": "llm_documentation", "params": params})
|
||||
return _api_result(resp, {201})
|
||||
# #endregion AgentChat.Tools.LlmDocumentation
|
||||
|
||||
|
||||
# #region AgentChat.Tools.LlmValidation.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,llm,validation]
|
||||
class RunLlmValidationInput(BaseModel):
|
||||
environment_id: str = Field(description="Environment ID")
|
||||
dashboard_id: int | None = Field(default=None, description="Single dashboard ID")
|
||||
@@ -358,8 +503,15 @@ class RunLlmValidationInput(BaseModel):
|
||||
provider_id: str | None = Field(default=None, description="Optional LLM provider ID; active provider is used if omitted")
|
||||
name: str | None = Field(default=None, description="Optional validation policy name")
|
||||
policy_id: str | None = Field(default=None, description="Existing validation policy ID to run immediately")
|
||||
# #endregion AgentChat.Tools.LlmValidation.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.LlmValidation [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,llm,validation]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Create and immediately run an LLM dashboard validation policy, or run an existing policy.
|
||||
# @PRE User authenticated via dual-identity JWT. LLM provider configured.
|
||||
# @POST Returns validation task result.
|
||||
# @SIDE_EFFECT HTTP POST to FastAPI /api/validation-tasks + /api/validation-tasks/{id}/run.
|
||||
@tool(args_schema=RunLlmValidationInput)
|
||||
async def run_llm_validation(
|
||||
environment_id: str,
|
||||
@@ -409,23 +561,39 @@ async def run_llm_validation(
|
||||
return f"Validation policy created but response could not be parsed: {exc}. Raw: {create_resp.text}"
|
||||
run_resp = await _post(f"/api/validation-tasks/{created_id}/run")
|
||||
return _api_result(run_resp)
|
||||
# #endregion AgentChat.Tools.LlmValidation
|
||||
|
||||
|
||||
# #region AgentChat.Tools.ListMaintenance [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,maintenance,list]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF List active and completed maintenance events.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns maintenance events text.
|
||||
# @SIDE_EFFECT HTTP GET to FastAPI /api/maintenance/events.
|
||||
@tool
|
||||
async def list_maintenance_events() -> str:
|
||||
"""List active and completed maintenance events."""
|
||||
resp = await _get("/api/maintenance/events")
|
||||
return _api_result(resp)
|
||||
# #endregion AgentChat.Tools.ListMaintenance
|
||||
|
||||
|
||||
# #region AgentChat.Tools.StartMaintenance.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,maintenance]
|
||||
class StartMaintenanceInput(BaseModel):
|
||||
tables: list[str] = Field(description="Affected table names")
|
||||
environment_id: str = Field(description="Target environment ID")
|
||||
start_time: str = Field(description="ISO datetime for maintenance start")
|
||||
end_time: str | None = Field(default=None, description="Optional ISO datetime for maintenance end")
|
||||
message: str | None = Field(default=None, description="Optional banner message")
|
||||
# #endregion AgentChat.Tools.StartMaintenance.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.StartMaintenance [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,maintenance,start]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Start a maintenance event and apply banners to affected dashboards.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns maintenance start result (200/202).
|
||||
# @SIDE_EFFECT HTTP POST to FastAPI /api/maintenance/start.
|
||||
@tool(args_schema=StartMaintenanceInput)
|
||||
async def start_maintenance(
|
||||
tables: list[str],
|
||||
@@ -444,14 +612,23 @@ async def start_maintenance(
|
||||
}
|
||||
resp = await _post("/api/maintenance/start", payload)
|
||||
return _api_result(resp, {200, 202})
|
||||
# #endregion AgentChat.Tools.StartMaintenance
|
||||
|
||||
|
||||
# #region AgentChat.Tools.EndMaintenance.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,maintenance]
|
||||
class EndMaintenanceInput(BaseModel):
|
||||
maintenance_id: str | None = Field(default=None, description="Maintenance event ID; omit when end_all=True")
|
||||
environment_id: str | None = Field(default=None, description="Optional environment scope for end_all")
|
||||
end_all: bool = Field(default=False, description="End all active maintenance events")
|
||||
# #endregion AgentChat.Tools.EndMaintenance.Schema
|
||||
|
||||
|
||||
# #region AgentChat.Tools.EndMaintenance [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,maintenance,end]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF End one maintenance event, or end all active events when end_all is true.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns maintenance end result (202).
|
||||
# @SIDE_EFFECT HTTP POST to FastAPI /api/maintenance/{id}/end or /api/maintenance/end-all.
|
||||
@tool(args_schema=EndMaintenanceInput)
|
||||
async def end_maintenance(
|
||||
maintenance_id: str | None = None,
|
||||
@@ -467,9 +644,16 @@ async def end_maintenance(
|
||||
return "Error: maintenance_id is required unless end_all=true."
|
||||
resp = await _post(f"/api/maintenance/{maintenance_id}/end")
|
||||
return _api_result(resp, {202})
|
||||
# #endregion AgentChat.Tools.EndMaintenance
|
||||
|
||||
|
||||
# ── All available tools for the agent ──
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Tool registry
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# #region AgentChat.Tools.GetAll [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,registry]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Return the full list of registered LangChain @tool functions.
|
||||
def get_all_tools() -> list:
|
||||
return [
|
||||
show_capabilities,
|
||||
@@ -490,10 +674,15 @@ def get_all_tools() -> list:
|
||||
start_maintenance,
|
||||
end_maintenance,
|
||||
]
|
||||
# #endregion AgentChat.Tools.GetAll
|
||||
|
||||
|
||||
# #region AgentChat.Tools.GetForQuery [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,registry,intent]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Return a compact, intent-scoped tool set to keep small-context models usable.
|
||||
# @RATIONALE Some LLMs (gemma) struggle with large tool lists. This reduces the agent's
|
||||
# tool surface to only those relevant to the user's intent.
|
||||
def get_tools_for_query(query: str, *, prefetch_available: bool = False) -> list:
|
||||
"""Return a compact, intent-scoped tool set to keep small-context models usable."""
|
||||
text = (query or "").lower()
|
||||
selected = [show_capabilities]
|
||||
matched_intent = False
|
||||
@@ -549,4 +738,5 @@ def get_tools_for_query(query: str, *, prefetch_available: bool = False) -> list
|
||||
for tool_obj in selected:
|
||||
unique[tool_obj.name] = tool_obj
|
||||
return list(unique.values())
|
||||
# #endregion AgentChat.Tools.GetForQuery
|
||||
# #endregion AgentChat.Tools
|
||||
|
||||
@@ -25,7 +25,7 @@ function getGradioBaseUrl(): string {
|
||||
export interface ConnectionManagerCallbacks {
|
||||
onConnected: (client: GradioClient) => void;
|
||||
onDisconnected: () => void;
|
||||
onDisconnectedPermanent: () => void;
|
||||
onDisconnectedPermanent: (error?: string) => void;
|
||||
onStreamingStateChange: (s: StreamingState) => void;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export class ConnectionManager {
|
||||
this.cb.onStreamingStateChange("idle");
|
||||
log("AgentChat.ConnectionManager", "REFLECT", "Reconnected successfully");
|
||||
} catch (e: unknown) {
|
||||
this.cb.onDisconnectedPermanent();
|
||||
this.cb.onDisconnectedPermanent(e instanceof Error ? e.message : "Manual reconnect failed");
|
||||
log("AgentChat.ConnectionManager", "EXPLORE", "Reconnect failed", {}, e instanceof Error ? e.message : "Unknown");
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export class ConnectionManager {
|
||||
this._reconnectTimer = setInterval(async () => {
|
||||
this._reconnectAttempts++;
|
||||
if (this._reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
|
||||
this.cb.onDisconnectedPermanent();
|
||||
this.cb.onDisconnectedPermanent("Max reconnect attempts reached");
|
||||
this._clearTimer();
|
||||
log("AgentChat.ConnectionManager", "EXPLORE", "Max reconnect attempts reached", { attempts: this._reconnectAttempts }, "Max 5 reconnect attempts exhausted");
|
||||
return;
|
||||
@@ -96,8 +96,8 @@ export class ConnectionManager {
|
||||
try {
|
||||
const client = await Client.connect(getGradioBaseUrl());
|
||||
this.onReconnect(client);
|
||||
} catch {
|
||||
log("AgentChat.ConnectionManager", "EXPLORE", "Reconnect attempt failed", { attempt: this._reconnectAttempts }, "Client.connect threw");
|
||||
} catch (e: unknown) {
|
||||
log("AgentChat.ConnectionManager", "EXPLORE", "Reconnect attempt failed", { attempt: this._reconnectAttempts }, e instanceof Error ? e.message : "Client.connect threw");
|
||||
}
|
||||
}, RECONNECT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface StreamProcessorHost {
|
||||
error: string | null;
|
||||
pendingThreadId: string | null;
|
||||
currentConversationId: string | null;
|
||||
confirmationMessage: string | null;
|
||||
pendingToolName: string | null;
|
||||
pendingToolArgs: Record<string, unknown>;
|
||||
pendingConfirmationRisk: "read" | "write" | "unknown" | null;
|
||||
pendingRiskLevel: string | null;
|
||||
captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void;
|
||||
}
|
||||
|
||||
@@ -177,7 +182,15 @@ export class StreamProcessor {
|
||||
|
||||
case "confirm_required":
|
||||
this.host.streamingState = "awaiting_confirmation";
|
||||
this.host.pendingThreadId = meta.thread_id ?? null;
|
||||
this.host.pendingThreadId = meta.thread_id ?? this.host.currentConversationId ?? null;
|
||||
this.host.confirmationMessage = meta.prompt ?? meta.detail ?? null;
|
||||
this.host.pendingToolName = meta.tool_name ?? meta.tool ?? null;
|
||||
this.host.pendingToolArgs = meta.tool_args ?? {};
|
||||
this.host.pendingConfirmationRisk =
|
||||
meta.risk === "read" || meta.risk === "write" || meta.risk === "unknown"
|
||||
? meta.risk
|
||||
: null;
|
||||
this.host.pendingRiskLevel = meta.risk_level ?? null;
|
||||
break;
|
||||
|
||||
case "confirm_resolved":
|
||||
@@ -185,6 +198,8 @@ export class StreamProcessor {
|
||||
if (msg.text) this.host.partialText += msg.text + "\n\n";
|
||||
this.host.streamingState = meta.result === "confirmed" ? "streaming" : "idle";
|
||||
this.host.pendingThreadId = null;
|
||||
this.host.pendingConfirmationRisk = null;
|
||||
this.host.pendingRiskLevel = null;
|
||||
break;
|
||||
|
||||
case "error":
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// @INVARIANT HITL resume: confirmation via second submit() with additional_inputs=[conversationId, "confirm"|"deny"]. Primary stream TERMINATES on confirm_required, second stream resumes from checkpoint.
|
||||
// @INVARIANT Optimistic cancel: submission.cancel() stops generation, partial text preserved.
|
||||
// @INVARIANT Conversation list loaded from FastAPI REST — Gradio serves only live agent streaming.
|
||||
// @INVARIANT Screen chrome state lives in the model: debug panel and conversation sidebar are view-rendered only.
|
||||
// @INVARIANT DECOMPOSITION GATE (630→~340 lines): Stream processing → AgentChat.StreamProcessor; Connection lifecycle → AgentChat.ConnectionManager; Persistence → AgentChat.LocalStorage; Types → AgentChatTypes.
|
||||
// @STATE idle | streaming | awaiting_confirmation | error | disconnected | disconnected_permanent
|
||||
// @ACTION sendMessage(text, files?) — submit("/chat", {text, files}, [conversationId, null]), iterate stream events, process metadata.
|
||||
@@ -52,11 +53,25 @@ export interface AgentChatModelOptions {
|
||||
envId?: string;
|
||||
}
|
||||
|
||||
export type AgentPhase = "ready" | "thinking" | "tooling" | "confirming" | "failed" | "offline";
|
||||
export type AgentPhaseTone = "success" | "warning" | "destructive" | "muted";
|
||||
export type ConfirmationRisk = "read" | "write" | "unknown";
|
||||
|
||||
export interface AgentQuickAction {
|
||||
id: "dashboards" | "migration" | "tasks" | "health";
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export class AgentChatModel {
|
||||
// ── Atoms ──────────────────────────────────────────────────────
|
||||
messages: AgentMessage[] = $state([]);
|
||||
conversations: Conversation[] = $state([]);
|
||||
currentConversationId: string | null = $state(null);
|
||||
isDebugPanelOpen: boolean = $state(false);
|
||||
isConversationSidebarOpen: boolean = $state(false);
|
||||
streamingState: StreamingState = $state("idle");
|
||||
connectionState: ConnectionState = $state("connected");
|
||||
error: string | null = $state(null);
|
||||
@@ -66,6 +81,18 @@ export class AgentChatModel {
|
||||
isLoadingHistory: boolean = $state(false);
|
||||
inputText: string = $state("");
|
||||
pendingThreadId: string | null = $state(null);
|
||||
/** Human-readable confirmation prompt (replaces misuse of `error` for this purpose). */
|
||||
confirmationMessage: string | null = $state(null);
|
||||
/** Tool name being requested for HITL confirmation. */
|
||||
pendingToolName: string | null = $state(null);
|
||||
/** Tool arguments for the pending HITL confirmation call. */
|
||||
pendingToolArgs: Record<string, unknown> = $state({});
|
||||
/** Backend-provided coarse confirmation risk; heuristic is fallback only. */
|
||||
pendingConfirmationRisk: ConfirmationRisk | null = $state(null);
|
||||
/** Backend-provided detailed risk level: safe, guarded, dangerous, unknown. */
|
||||
pendingRiskLevel: string | null = $state(null);
|
||||
/** Set to true when user clicks Stop — prevents false 'agent unavailable' fallback. */
|
||||
_userCancelled: boolean = $state(false);
|
||||
userId: string = "";
|
||||
userJwt: string = "";
|
||||
envId: string = "";
|
||||
@@ -79,6 +106,36 @@ export class AgentChatModel {
|
||||
private _historyHasNext: boolean = $state(false);
|
||||
private _messageQueue: Array<{ text: string; files?: File[] }> = $state([]);
|
||||
private _processingQueue: boolean = false;
|
||||
readonly quickActions: AgentQuickAction[] = [
|
||||
{
|
||||
id: "dashboards",
|
||||
icon: "dashboard",
|
||||
labelKey: "quick_dashboards",
|
||||
descriptionKey: "quick_dashboards_desc",
|
||||
prompt: "Покажи доступные дашборды",
|
||||
},
|
||||
{
|
||||
id: "migration",
|
||||
icon: "layers",
|
||||
labelKey: "quick_migration",
|
||||
descriptionKey: "quick_migration_desc",
|
||||
prompt: "Запусти миграцию",
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
icon: "activity",
|
||||
labelKey: "quick_tasks",
|
||||
descriptionKey: "quick_tasks_desc",
|
||||
prompt: "Проверь статус текущих задач",
|
||||
},
|
||||
{
|
||||
id: "health",
|
||||
icon: "warning",
|
||||
labelKey: "quick_health",
|
||||
descriptionKey: "quick_health_desc",
|
||||
prompt: "Проверь статус системы",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Sub-helpers ────────────────────────────────────────────────
|
||||
readonly connection: ConnectionManager;
|
||||
@@ -102,6 +159,89 @@ export class AgentChatModel {
|
||||
);
|
||||
queuePosition = $derived(this._messageQueue.length);
|
||||
conversationsHasNext = $derived(this._conversationsHasNext);
|
||||
conversationIdLabel = $derived(this.currentConversationId || "new");
|
||||
pendingThreadIdLabel = $derived(this.pendingThreadId || "none");
|
||||
userIdLabel = $derived(this.userId || "anonymous");
|
||||
envIdLabel = $derived(this.envId || "—");
|
||||
lastMessageIdLabel = $derived(this.messages.at(-1)?.id || "none");
|
||||
messageCountLabel = $derived(String(this.messages.length));
|
||||
conversationListItems = $derived(
|
||||
this.conversations.map((conversation) => ({
|
||||
...conversation,
|
||||
title: this.normalizeConversationTitle(conversation.title),
|
||||
})),
|
||||
);
|
||||
currentConversationTitle = $derived(
|
||||
this.currentConversationId
|
||||
? (this.conversationListItems.find((c) => c.id === this.currentConversationId)?.title || "Агент-чат")
|
||||
: "Агент-чат",
|
||||
);
|
||||
agentPhase = $derived.by((): AgentPhase => {
|
||||
if (this.connectionState !== "connected") return "offline";
|
||||
if (this.streamingState === "awaiting_confirmation") return "confirming";
|
||||
if (this.streamingState === "error") return "failed";
|
||||
if (this.activeToolCalls.some((tool) => tool.status === "executing")) return "tooling";
|
||||
if (this.streamingState === "streaming") return "thinking";
|
||||
return "ready";
|
||||
});
|
||||
agentPhaseTone = $derived.by((): AgentPhaseTone => {
|
||||
if (this.agentPhase === "offline" || this.agentPhase === "failed") return "destructive";
|
||||
if (this.agentPhase === "confirming" || this.agentPhase === "tooling") return "warning";
|
||||
if (this.agentPhase === "ready") return "success";
|
||||
return "muted";
|
||||
});
|
||||
confirmationRisk = $derived.by((): ConfirmationRisk => {
|
||||
if (this.pendingConfirmationRisk) return this.pendingConfirmationRisk;
|
||||
if (this.pendingRiskLevel === "safe") return "read";
|
||||
if (this.pendingRiskLevel === "guarded" || this.pendingRiskLevel === "dangerous") return "write";
|
||||
const tool = (this.pendingToolName || "").toLowerCase();
|
||||
if (!tool) return "unknown";
|
||||
if (
|
||||
tool.startsWith("list_") ||
|
||||
tool.startsWith("get_") ||
|
||||
tool.startsWith("show_") ||
|
||||
tool.startsWith("search_") ||
|
||||
tool.startsWith("check_") ||
|
||||
tool.includes("status") ||
|
||||
tool.includes("summary")
|
||||
) {
|
||||
return "read";
|
||||
}
|
||||
return "write";
|
||||
});
|
||||
confirmationTone = $derived.by((): AgentPhaseTone => {
|
||||
if (this.confirmationRisk === "write") return "warning";
|
||||
if (this.confirmationRisk === "read") return "success";
|
||||
return "muted";
|
||||
});
|
||||
confirmationTitleKey = $derived.by(() => {
|
||||
if (this.confirmationRisk === "read") return "confirmation_read_title";
|
||||
if (this.confirmationRisk === "write") return "confirmation_write_title";
|
||||
return "confirmation_card_title";
|
||||
});
|
||||
confirmationDescriptionKey = $derived.by(() => {
|
||||
if (!this.pendingThreadId) return "confirmation_missing_checkpoint";
|
||||
if (this.confirmationRisk === "read") return "confirmation_read_description";
|
||||
if (this.confirmationRisk === "write") return "confirmation_scope_fallback";
|
||||
return "confirmation_unknown_description";
|
||||
});
|
||||
confirmationMessageOverride = $derived.by(() => {
|
||||
const message = (this.confirmationMessage || "").trim();
|
||||
if (!message) return "";
|
||||
const genericMessages = new Set([
|
||||
"Подтвердить операцию?",
|
||||
"Подтвердите действие",
|
||||
"Confirm operation?",
|
||||
"Confirm action",
|
||||
]);
|
||||
return genericMessages.has(message) ? "" : message;
|
||||
});
|
||||
pendingToolLabel = $derived.by(() => {
|
||||
if (!this.pendingToolName) return "";
|
||||
return this.pendingToolName
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
});
|
||||
|
||||
constructor(options?: AgentChatModelOptions) {
|
||||
if (options?.userId) this.userId = options.userId;
|
||||
@@ -115,9 +255,10 @@ export class AgentChatModel {
|
||||
this.streamingState = "disconnected";
|
||||
}
|
||||
},
|
||||
onDisconnectedPermanent: () => {
|
||||
onDisconnectedPermanent: (error?: string) => {
|
||||
this.connectionState = "disconnected_permanent";
|
||||
this.streamingState = "disconnected_permanent";
|
||||
if (error) this.error = error;
|
||||
},
|
||||
onStreamingStateChange: (s) => { this.streamingState = s; },
|
||||
};
|
||||
@@ -131,6 +272,43 @@ export class AgentChatModel {
|
||||
/** External callback to sync reactive values (userId, userJwt, envId) before each send */
|
||||
onBeforeSend?: () => void;
|
||||
|
||||
toggleDebugPanel(): void {
|
||||
this.isDebugPanelOpen = !this.isDebugPanelOpen;
|
||||
}
|
||||
|
||||
setDebugPanelOpen(open: boolean): void {
|
||||
this.isDebugPanelOpen = open;
|
||||
}
|
||||
|
||||
toggleConversationSidebar(): void {
|
||||
this.isConversationSidebarOpen = !this.isConversationSidebarOpen;
|
||||
}
|
||||
|
||||
setConversationSidebarOpen(open: boolean): void {
|
||||
this.isConversationSidebarOpen = open;
|
||||
}
|
||||
|
||||
async selectConversation(id: string, closeSidebar: boolean = false): Promise<void> {
|
||||
// Commit partial streaming text to the CURRENT conversation BEFORE switching.
|
||||
// Without this, switching dialogs during active streaming silently loses
|
||||
// the in-progress response.
|
||||
if (this.streamingState === "streaming" && this.currentConversationId && id !== this.currentConversationId) {
|
||||
this._commitStreamingPartial();
|
||||
}
|
||||
this.currentConversationId = id;
|
||||
await this.loadHistory(id);
|
||||
if (closeSidebar) this.isConversationSidebarOpen = false;
|
||||
}
|
||||
|
||||
normalizeConversationTitle(rawTitle: unknown): string {
|
||||
const title = String(rawTitle ?? "")
|
||||
.replace(/\s*\[PRE-FETCHED DATA[\s\S]*$/i, "")
|
||||
.replace(/\s+Available(?:\s+\S+)*$/i, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return title || "Новый диалог";
|
||||
}
|
||||
|
||||
async sendMessage(text: string, files?: File[]): Promise<void> {
|
||||
// Sync reactive values from the page component
|
||||
this.onBeforeSend?.();
|
||||
@@ -154,14 +332,27 @@ export class AgentChatModel {
|
||||
}
|
||||
const convId = this.currentConversationId;
|
||||
this.streamingState = "streaming";
|
||||
this._userCancelled = false;
|
||||
this.error = null;
|
||||
this.partialText = "";
|
||||
this.partialTokens = [];
|
||||
this.activeToolCalls = [];
|
||||
this.confirmationMessage = null;
|
||||
this.pendingToolName = null;
|
||||
this.pendingToolArgs = {};
|
||||
this.pendingConfirmationRisk = null;
|
||||
this.pendingRiskLevel = null;
|
||||
log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) });
|
||||
|
||||
try {
|
||||
this._submission = this._client!.submit("chat",
|
||||
// Guard: client may be null if connection reset during send attempt.
|
||||
if (!this._client) {
|
||||
this.streamingState = "error";
|
||||
this.error = "Agent client not available — please reconnect.";
|
||||
log("AgentChat.Model", "EXPLORE", "Submit rejected: _client is null", {}, this.error);
|
||||
return;
|
||||
}
|
||||
this._submission = this._client.submit("chat",
|
||||
[{ text, files }, null, convId, null, this.userId, this.userJwt, this.envId],
|
||||
);
|
||||
|
||||
@@ -217,27 +408,131 @@ export class AgentChatModel {
|
||||
cancelGeneration(): void {
|
||||
if (this.streamingState === "idle") return;
|
||||
this._submission?.cancel();
|
||||
this._userCancelled = true;
|
||||
this.streamingState = "idle";
|
||||
this._submission = null;
|
||||
this.confirmationMessage = null;
|
||||
this.pendingToolName = null;
|
||||
this.pendingToolArgs = {};
|
||||
this.pendingConfirmationRisk = null;
|
||||
this.pendingRiskLevel = null;
|
||||
log("AgentChat.Model", "REASON", "Generation cancelled");
|
||||
}
|
||||
|
||||
private _clearPendingConfirmation(): void {
|
||||
this.streamingState = "idle";
|
||||
this._submission = null;
|
||||
this.pendingThreadId = null;
|
||||
this.confirmationMessage = null;
|
||||
this.pendingToolName = null;
|
||||
this.pendingToolArgs = {};
|
||||
this.pendingConfirmationRisk = null;
|
||||
this.pendingRiskLevel = null;
|
||||
}
|
||||
|
||||
/** Commit in-progress streaming text + tool calls as an assistant message.
|
||||
* Called before switching conversations or creating a new one while streaming. */
|
||||
private _commitStreamingPartial(): void {
|
||||
const partialText = this.partialText;
|
||||
const toolCalls = [...this.activeToolCalls];
|
||||
if (!partialText && toolCalls.length === 0) return;
|
||||
|
||||
const text = partialText
|
||||
|| toolCalls.map((tc) => `🛠️ ${tc.tool}${tc.output ? " ✅" : ""}`).join("\n")
|
||||
|| "[Генерация прервана]";
|
||||
this.messages = [
|
||||
...this.messages,
|
||||
{
|
||||
id: `msg-${Date.now()}`,
|
||||
conversation_id: this.currentConversationId || "",
|
||||
role: "assistant",
|
||||
text,
|
||||
toolCalls,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
this._saveToStorage();
|
||||
log("AgentChat.Model", "REFLECT", "Committed streaming partial on context switch", {
|
||||
textLen: partialText.length,
|
||||
toolCalls: toolCalls.length,
|
||||
conversationId: this.currentConversationId,
|
||||
});
|
||||
}
|
||||
|
||||
private _appendLocalDeniedMessage(): void {
|
||||
this.messages = [
|
||||
...this.messages,
|
||||
{
|
||||
id: `deny-${Date.now()}`,
|
||||
conversation_id: this.currentConversationId || "",
|
||||
role: "assistant",
|
||||
text: "⏹️ Операция отменена",
|
||||
toolCalls: [],
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async resumeConfirm(action: "confirm" | "deny"): Promise<void> {
|
||||
if (this.streamingState !== "awaiting_confirmation") return;
|
||||
if (!this.pendingThreadId || !this.currentConversationId) return;
|
||||
if (!this.pendingThreadId || !this.currentConversationId) {
|
||||
if (action === "deny") {
|
||||
this._appendLocalDeniedMessage();
|
||||
this._clearPendingConfirmation();
|
||||
this._persistMessages();
|
||||
log("AgentChat.Model", "REASON", "HITL deny handled locally without checkpoint", {
|
||||
hasThreadId: Boolean(this.pendingThreadId),
|
||||
hasConversationId: Boolean(this.currentConversationId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.error = "Не удалось подтвердить действие: отсутствует checkpoint. Повторите запрос.";
|
||||
this._clearPendingConfirmation();
|
||||
log("AgentChat.Model", "EXPLORE", "HITL confirm blocked: missing checkpoint", {
|
||||
hasThreadId: Boolean(this.pendingThreadId),
|
||||
hasConversationId: Boolean(this.currentConversationId),
|
||||
}, this.error);
|
||||
return;
|
||||
}
|
||||
|
||||
log("AgentChat.Model", "REASON", `HITL resume: ${action}`, { threadId: this.pendingThreadId });
|
||||
|
||||
try {
|
||||
this._userCancelled = false;
|
||||
this.streamingState = "streaming";
|
||||
const submission = this._client!.submit("chat",
|
||||
[{ text: action === "confirm" ? "confirm" : "deny", files: null }, null, this.currentConversationId, action, this.userId, this.userJwt, this.envId],
|
||||
if (!this._client) {
|
||||
this.streamingState = "error";
|
||||
this.error = "Agent client not available — please reconnect.";
|
||||
log("AgentChat.Model", "EXPLORE", "HITL resume rejected: _client is null", {}, this.error);
|
||||
return;
|
||||
}
|
||||
this._submission = this._client.submit("chat",
|
||||
[{ text: action === "confirm" ? "confirm" : "deny", files: [] }, null, this.currentConversationId, action, this.userId, this.userJwt, this.envId],
|
||||
);
|
||||
|
||||
await this.streamProcessor.processStream(submission, this.currentConversationId);
|
||||
this.streamingState = "idle";
|
||||
// Safety timeout: same pattern as _sendNow — prevents hanging
|
||||
// if Gradio stream closes without a clean completion event.
|
||||
const streamDone = await Promise.race([
|
||||
this.streamProcessor.processStream(this._submission, this.currentConversationId),
|
||||
this.streamProcessor.streamCloseWatcher(this._client!, 180_000),
|
||||
new Promise<false>((resolve) => setTimeout(() => resolve(false), 180_000)),
|
||||
]);
|
||||
|
||||
if (streamDone === false) {
|
||||
try { this._submission?.return?.(); } catch { /* ignore */ }
|
||||
}
|
||||
if (this.streamingState !== "awaiting_confirmation") {
|
||||
this.streamingState = "idle";
|
||||
}
|
||||
this._submission = null;
|
||||
this.pendingThreadId = null;
|
||||
this.confirmationMessage = null;
|
||||
this.pendingToolName = null;
|
||||
this.pendingToolArgs = {};
|
||||
this.pendingConfirmationRisk = null;
|
||||
this.pendingRiskLevel = null;
|
||||
this._persistMessages();
|
||||
this.loadConversations(true);
|
||||
} catch (e: unknown) {
|
||||
this.streamingState = "error";
|
||||
this.error = e instanceof Error ? e.message : "Resume failed";
|
||||
@@ -259,12 +554,20 @@ export class AgentChatModel {
|
||||
title: item.title ?? "",
|
||||
updated_at: item.updated_at ?? "",
|
||||
message_count: item.message_count ?? 0,
|
||||
last_role: item.last_role ?? null,
|
||||
has_tool_calls: item.has_tool_calls ?? false,
|
||||
has_error: item.has_error ?? false,
|
||||
risk_level: item.risk_level ?? null,
|
||||
}))
|
||||
: [...this.conversations, ...items.map((item: Record<string, unknown>) => ({
|
||||
id: item.conversation_id ?? item.id ?? "",
|
||||
title: item.title ?? "",
|
||||
updated_at: item.updated_at ?? "",
|
||||
message_count: item.message_count ?? 0,
|
||||
last_role: item.last_role ?? null,
|
||||
has_tool_calls: item.has_tool_calls ?? false,
|
||||
has_error: item.has_error ?? false,
|
||||
risk_level: item.risk_level ?? null,
|
||||
}))];
|
||||
this._conversationsHasNext = Boolean(res.has_next);
|
||||
this._conversationsPage++;
|
||||
@@ -284,6 +587,10 @@ export class AgentChatModel {
|
||||
title: item.title ?? "",
|
||||
updated_at: item.updated_at ?? "",
|
||||
message_count: item.message_count ?? 0,
|
||||
last_role: item.last_role ?? null,
|
||||
has_tool_calls: item.has_tool_calls ?? false,
|
||||
has_error: item.has_error ?? false,
|
||||
risk_level: item.risk_level ?? null,
|
||||
}));
|
||||
this._conversationsHasNext = Boolean(res.has_next);
|
||||
this._conversationsPage++;
|
||||
@@ -296,16 +603,22 @@ export class AgentChatModel {
|
||||
async loadHistory(conversationId: string | null = null): Promise<void> {
|
||||
this.isLoadingHistory = true;
|
||||
this.error = null;
|
||||
// Reset stream state when switching conversations — prevents state leak
|
||||
// from a failed/cancelled stream in the previous conversation.
|
||||
|
||||
// Cancel any in-flight submission first to prevent stale continuation
|
||||
// from writing to the new conversation's localStorage.
|
||||
// _userCancelled suppresses fallback "Agent unavailable" messages.
|
||||
try { this._submission?.cancel(); } catch { /* ignore */ }
|
||||
this._userCancelled = true;
|
||||
this._submission = null;
|
||||
this.streamingState = "idle";
|
||||
this.pendingThreadId = null;
|
||||
this.activeToolCalls = [];
|
||||
this.partialText = "";
|
||||
this.confirmationMessage = null;
|
||||
this.pendingToolName = null;
|
||||
this.pendingToolArgs = {};
|
||||
this.pendingConfirmationRisk = null;
|
||||
this.pendingRiskLevel = null;
|
||||
log("AgentChat.Model", "REASON", "Loading history", { conversationId });
|
||||
try {
|
||||
const targetId = conversationId ?? this.currentConversationId;
|
||||
@@ -340,6 +653,13 @@ export class AgentChatModel {
|
||||
}
|
||||
|
||||
createConversation(): void {
|
||||
// Commit streaming partial text before starting a new conversation
|
||||
if (this.streamingState === "streaming") {
|
||||
this._commitStreamingPartial();
|
||||
this._userCancelled = true;
|
||||
try { this._submission?.cancel(); } catch { /* ignore */ }
|
||||
this._submission = null;
|
||||
}
|
||||
if (this.currentConversationId && this.messages.length > 0) {
|
||||
this._saveToStorage();
|
||||
}
|
||||
@@ -350,6 +670,12 @@ export class AgentChatModel {
|
||||
this.partialText = "";
|
||||
this.activeToolCalls = [];
|
||||
this.pendingThreadId = null;
|
||||
this.confirmationMessage = null;
|
||||
this.pendingToolName = null;
|
||||
this.pendingToolArgs = {};
|
||||
this.pendingConfirmationRisk = null;
|
||||
this.pendingRiskLevel = null;
|
||||
this._userCancelled = false;
|
||||
this._historyPage = 1;
|
||||
this._historyHasNext = false;
|
||||
log("AgentChat.Model", "REASON", "New conversation created");
|
||||
|
||||
@@ -13,12 +13,11 @@
|
||||
import { sidebarStore } from "$lib/stores/sidebar.svelte.js";
|
||||
import { auth } from "$lib/auth/store.svelte.js";
|
||||
import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js";
|
||||
import { Icon, Button } from "$lib/ui";
|
||||
|
||||
let model = $state<AgentChatModel | null>(null);
|
||||
let sidebarOpen = $state(true);
|
||||
|
||||
let sidebarExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
|
||||
import { Icon } from "$lib/ui";
|
||||
|
||||
// Get current user identity + JWT for Gradio conversation persistence and tool auth
|
||||
// $auth is Svelte auto-subscription syntax for stores with .subscribe()
|
||||
@@ -37,6 +36,10 @@
|
||||
m.envId = $environmentContextStore.selectedEnvId ?? "";
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (model) syncModel(model);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
@@ -44,6 +47,13 @@
|
||||
const m = new AgentChatModel();
|
||||
m.onBeforeSend = () => syncModel(m);
|
||||
syncModel(m);
|
||||
const desktopQuery = window.matchMedia("(min-width: 768px)");
|
||||
const syncSidebarForViewport = (matches: boolean) => {
|
||||
m.setConversationSidebarOpen(matches);
|
||||
};
|
||||
syncSidebarForViewport(desktopQuery.matches);
|
||||
const handleViewportChange = (event: MediaQueryListEvent) => syncSidebarForViewport(event.matches);
|
||||
desktopQuery.addEventListener("change", handleViewportChange);
|
||||
model = m;
|
||||
m.connectionState = "disconnected";
|
||||
|
||||
@@ -58,29 +68,28 @@
|
||||
m.connectionState = "disconnected";
|
||||
});
|
||||
|
||||
return () => { _initialized = false; };
|
||||
return () => {
|
||||
desktopQuery.removeEventListener("change", handleViewportChange);
|
||||
_initialized = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if model}
|
||||
<!-- Fixed full-height chat below TopNavbar, avoids breadcrumbs/PROD-context/footer flow -->
|
||||
<div
|
||||
class="fixed top-16 right-0 bottom-0 z-10 flex"
|
||||
class:left-60={sidebarExpanded}
|
||||
class:left-16={!sidebarExpanded}
|
||||
class={`fixed top-16 right-0 bottom-0 left-0 z-10 flex ${sidebarExpanded ? "md:left-60" : "md:left-16"}`}
|
||||
>
|
||||
<!-- Sidebar group: conversation list + toggle button (desktop) -->
|
||||
<div class="relative hidden md:flex shrink-0">
|
||||
{#if sidebarOpen}
|
||||
{#if model.isConversationSidebarOpen}
|
||||
<ConversationList
|
||||
conversations={model.conversations}
|
||||
searchFieldId="agent-conversation-search-desktop"
|
||||
conversations={model.conversationListItems}
|
||||
currentConversationId={model.currentConversationId}
|
||||
isLoading={model.isLoadingHistory}
|
||||
hasNext={model.conversationsHasNext}
|
||||
onselect={(id) => {
|
||||
model!.currentConversationId = id;
|
||||
model!.loadHistory(id);
|
||||
}}
|
||||
onselect={(id) => model!.selectConversation(id)}
|
||||
ondelete={(id) => model!.deleteConversation(id)}
|
||||
onloadmore={() => model!.loadConversations(false)}
|
||||
onsearch={(q: string) => model!.searchConversations(q)}
|
||||
@@ -88,14 +97,16 @@
|
||||
{/if}
|
||||
|
||||
<!-- Toggle button — at right edge of sidebar group -->
|
||||
<!-- @RATIONALE Raw <button> (not $lib/ui/Button): ultra-custom chrome with w-6 h-12, conditional border-l-0/rounded-l-lg.
|
||||
Button's variant/size system conflicts — no variant matches the border/shadow/size combination needed for this UI control. -->
|
||||
<button
|
||||
class="absolute -right-3 top-4 z-10 flex items-center justify-center w-6 h-12 rounded-r-lg bg-surface-card border border-border text-text-muted hover:text-text shadow-sm transition"
|
||||
class:border-l-0={sidebarOpen}
|
||||
class:rounded-l-lg={!sidebarOpen}
|
||||
onclick={() => sidebarOpen = !sidebarOpen}
|
||||
aria-label={sidebarOpen ? "Закрыть список диалогов" : "Открыть список диалогов"}
|
||||
class:border-l-0={model.isConversationSidebarOpen}
|
||||
class:rounded-l-lg={!model.isConversationSidebarOpen}
|
||||
onclick={() => model!.toggleConversationSidebar()}
|
||||
aria-label={model.isConversationSidebarOpen ? "Закрыть список диалогов" : "Открыть список диалогов"}
|
||||
>
|
||||
{#if sidebarOpen}
|
||||
{#if model.isConversationSidebarOpen}
|
||||
<Icon name="chevronLeft" size={14} />
|
||||
{:else}
|
||||
<Icon name="chevronRight" size={14} />
|
||||
@@ -104,34 +115,38 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar — overlay -->
|
||||
{#if sidebarOpen}
|
||||
{#if model.isConversationSidebarOpen}
|
||||
<div class="md:hidden fixed inset-0 z-50 flex">
|
||||
<div class="w-64 bg-surface-card border-r border-border shadow-xl">
|
||||
<div class="flex items-center justify-between p-3 border-b border-border">
|
||||
<span class="text-sm font-semibold text-text">Диалоги</span>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-lg p-1 text-text-muted hover:bg-surface-muted hover:text-text"
|
||||
onclick={() => sidebarOpen = false}
|
||||
onclick={() => model!.setConversationSidebarOpen(false)}
|
||||
>
|
||||
<Icon name="close" size={20} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationList
|
||||
conversations={model.conversations}
|
||||
searchFieldId="agent-conversation-search-mobile"
|
||||
conversations={model.conversationListItems}
|
||||
currentConversationId={model.currentConversationId}
|
||||
isLoading={model.isLoadingHistory}
|
||||
hasNext={model.conversationsHasNext}
|
||||
onselect={(id) => {
|
||||
model!.currentConversationId = id;
|
||||
model!.loadHistory(id);
|
||||
sidebarOpen = false;
|
||||
}}
|
||||
onselect={(id) => model!.selectConversation(id, true)}
|
||||
ondelete={(id) => model!.deleteConversation(id)}
|
||||
onloadmore={() => model!.loadConversations(false)}
|
||||
onsearch={(q: string) => model!.searchConversations(q)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 bg-black/30" onclick={() => sidebarOpen = false}></div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 bg-black/30"
|
||||
aria-label="Закрыть список диалогов"
|
||||
onclick={() => model!.setConversationSidebarOpen(false)}
|
||||
></button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user