diff --git a/backend/src/agent/tools.py b/backend/src/agent/tools.py index 8df69d41..3346977e 100644 --- a/backend/src/agent/tools.py +++ b/backend/src/agent/tools.py @@ -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 diff --git a/frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts b/frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts index 4064f13d..da271d3f 100644 --- a/frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts +++ b/frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts @@ -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); } diff --git a/frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts b/frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts index 4665bb20..ade0f157 100644 --- a/frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts +++ b/frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts @@ -23,6 +23,11 @@ export interface StreamProcessorHost { error: string | null; pendingThreadId: string | null; currentConversationId: string | null; + confirmationMessage: string | null; + pendingToolName: string | null; + pendingToolArgs: Record; + 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": diff --git a/frontend/src/lib/models/AgentChatModel.svelte.ts b/frontend/src/lib/models/AgentChatModel.svelte.ts index a516e61f..1b8c6272 100644 --- a/frontend/src/lib/models/AgentChatModel.svelte.ts +++ b/frontend/src/lib/models/AgentChatModel.svelte.ts @@ -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 = $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 { + // 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 { // 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 { 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((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) => ({ 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 { 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"); diff --git a/frontend/src/routes/agent/+page.svelte b/frontend/src/routes/agent/+page.svelte index d1adaf91..bee225ee 100644 --- a/frontend/src/routes/agent/+page.svelte +++ b/frontend/src/routes/agent/+page.svelte @@ -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(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; + }; }); {#if model}
- {#if sidebarOpen} + {#if model.isConversationSidebarOpen}
Диалоги - +
{ - 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)} />
-
sidebarOpen = false}>
+
{/if}