diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..471f9d5d2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +### What is the purpose of this change? + + Brief summary - what problem does this solve? + +### How was this change implemented? + + High-level approach - what files/components changed and why? + +### Key Design Decisions _(optional - delete if not applicable)_ + + Why did you choose this approach over alternatives? + +### How was this change tested? + +- [ ] Manual testing: [describe scenarios] +- [ ] Unit tests: [new/modified tests] +- [ ] Integration tests: [if applicable] +- [ ] Known limitations: [what wasn't tested] + +### Is there anything the reviewers should focus on/be aware of? + + Special attention areas, potential risks, or open questions diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1914b9ee9..66372eb9e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -247,6 +247,12 @@ jobs: echo "AMD64 Tag: ${AMD_TAG}" echo "ARM64 Tag: ${ARM_TAG}" + # OCI annotations to ensure unique manifest digest per commit + # This allows Prisma Cloud and other tools to index each tag uniquely + # while still benefiting from layer caching + COMMIT_SHA="${{ needs.prepare-metadata.outputs.commit_hash }}" + BUILD_TIME="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + # Convert comma-separated tags string to array and create manifest for each IFS=',' read -ra TAGS <<< "${{ steps.image_tags.outputs.tags }}" for TAG in "${TAGS[@]}"; do @@ -255,6 +261,9 @@ jobs: TAG=$(echo "$TAG" | xargs) echo "Creating manifest for tag: $TAG" docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.revision=${COMMIT_SHA}" \ + --annotation "index:org.opencontainers.image.created=${BUILD_TIME}" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ --tag "$TAG" \ "$AMD_TAG" \ "$ARM_TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6142d751a..888ffb7b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,11 @@ name: Release (PyPI & Docker) on: workflow_dispatch: inputs: + ref: + type: string + required: true + description: "Git ref to release from" + default: "main" version: type: choice required: true @@ -10,6 +15,12 @@ on: - patch - minor - major + default: patch + exact_version: + type: string + required: false + description: "Exact version to release (e.g., 1.13.2). Overrides 'version' input if provided." + default: "" skip_security_checks: type: boolean required: false @@ -39,6 +50,7 @@ jobs: uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 # Need enough history to find last non-skip-ci commit + ref: ${{ github.event.inputs.ref }} - name: Find Last RC-Tested Commit id: find-commit @@ -153,6 +165,7 @@ jobs: with: fetch-depth: 0 ssh-key: ${{ secrets.COMMIT_KEY }} + ref: ${{ github.event.inputs.ref }} - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 @@ -171,7 +184,7 @@ jobs: id: prep uses: SolaceDev/solace-public-workflows/.github/actions/hatch-release-prep@main with: - version: ${{ github.event.inputs.version }} + version: ${{ github.event.inputs.exact_version || github.event.inputs.version }} # Publish using Trusted Publishing - must be directly in workflow, not in composite action # See: https://docs.pypi.org/trusted-publishers/using-a-publisher/ @@ -191,6 +204,7 @@ jobs: build_and_push_docker: name: Build and Push to DockerHub needs: release + if: always() && (needs.release.result == 'success') uses: SolaceLabs/solace-agent-mesh/.github/workflows/build-push-dockerhub.yml@main with: version: ${{ needs.release.outputs.new_version }} diff --git a/.github/workflows/ui-ci.yml b/.github/workflows/ui-ci.yml index 6d3498641..9a0a1e0c2 100644 --- a/.github/workflows/ui-ci.yml +++ b/.github/workflows/ui-ci.yml @@ -5,14 +5,10 @@ on: push: branches: - "main" - paths: - - "client/webui/frontend/**" pull_request: types: [opened, synchronize] branches: - "main" - paths: - - "client/webui/frontend/**" permissions: contents: write @@ -25,13 +21,30 @@ permissions: repository-projects: read jobs: + check-paths: + name: "Check if UI files changed" + runs-on: ubuntu-latest + outputs: + should-run: ${{ steps.filter.outputs.ui }} + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Check for UI changes + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + ui: + - 'client/webui/frontend/**' + validate-conventional-commit: name: "Validate Conventional Commit" runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 @@ -63,13 +76,15 @@ jobs: ui-build-and-test: name: "Build and Test UI" + needs: check-paths + if: needs.check-paths.outputs.should-run == 'true' runs-on: ubuntu-latest defaults: run: working-directory: client/webui/frontend steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 @@ -218,11 +233,30 @@ jobs: python whitesource_vulnerability_checker.py " + ui-ci-status: + name: "UI CI Status" + runs-on: ubuntu-latest + needs: [check-paths, ui-build-and-test] + if: always() + steps: + - name: Check build status + run: | + if [[ "${{ needs.check-paths.outputs.should-run }}" == "false" ]]; then + echo "UI files not changed, skipping UI build and tests" + exit 0 + elif [[ "${{ needs.ui-build-and-test.result }}" == "success" ]]; then + echo "UI build/tests passed" + exit 0 + else + echo "UI build/tests failed" + exit 1 + fi + bump-version: - needs: ui-build-and-test + needs: [check-paths, ui-build-and-test] name: "Bump UI Version" runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref_name == github.event.repository.default_branch + if: github.event_name == 'push' && github.ref_name == github.event.repository.default_branch && needs.check-paths.outputs.should-run == 'true' outputs: new-tag: ${{ steps.bump.outputs.newTag }} defaults: @@ -230,7 +264,7 @@ jobs: working-directory: client/webui/frontend steps: - name: "Checkout source code" - uses: "actions/checkout@v4" + uses: "actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd" # v5.0.1 with: ref: ${{ github.ref }} token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 156d0c454..c24d92d44 100644 --- a/.gitignore +++ b/.gitignore @@ -146,7 +146,8 @@ cython_debug/ # PyPI configuration file .pypirc tmp -.sam +.sam +*.key playground.py # VS Code @@ -168,3 +169,4 @@ client/webui/frontend/static/ui-version.json # workaround requirements.txt not working in ci requirements.txt data/artifacts/ +CLAUDE.md \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 0a65a8163..7c5e0adf0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,10 +8,10 @@ "name": "SAM", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "python": "${workspaceFolder}/.venv/bin/python", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/gateways/webui_gateway_example.yaml examples/services/platform_service_example.yaml", + "args": "run examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/gateways/webui_gateway_example.yaml examples/services/platform_service_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -21,9 +21,9 @@ "name": "SAM (Agents Only)", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/agents/a2a_multimodal_example.yaml", + "args": "run examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/agents/a2a_multimodal_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -33,21 +33,45 @@ "name": "SAM (test)", "type": "debugpy", "request": "launch", + "module": "cli.main", + "console": "integratedTerminal", + "args": "run examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/gateways/webui_gateway_example.yaml examples/agents/test_agent_example.yaml examples/agents/a2a_multimodal_example.yaml", + "justMyCode": false, + "env": { + "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" + } + }, + { + "name": "SAM (workflow)", + "type": "debugpy", + "request": "launch", "module": "solace_ai_connector.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/gateways/webui_gateway_example.yaml examples/agents/test_agent_example.yaml examples/agents/a2a_multimodal_example.yaml", + "args": "--envfile .env examples/agents/orchestrator_example.yaml examples/agents/test_agent_example.yaml examples/agents/jira_bug_triage_workflow.yaml examples/gateways/webui_gateway_example.yaml examples/agents/advanced_workflow_test.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" } }, { - "name": "SAM (preset)", + "name": "SAM (new node types)", "type": "debugpy", "request": "launch", "module": "solace_ai_connector.main", "console": "integratedTerminal", - "args": "--envfile .env preset/agents/basic/main_orchestrator.yaml preset/agents/basic/webui.yaml preset/agents/markitdown_agents.yaml preset/agents/mermaid_agents.yaml preset/agents/web_agents.yaml", + "args": "--envfile .env examples/agents/orchestrator_example.yaml examples/gateways/webui_gateway_example.yaml examples/agents/all_node_types_workflow.yaml examples/agents/complex_branching_workflow.yaml examples/agents/workflow_to_workflow_example.yaml examples/agents/simple_nested_test.yaml", + "justMyCode": false, + "env": { + "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" + } + }, + { + "name": "SAM (preset)", + "type": "debugpy", + "request": "launch", + "module": "cli.main", + "console": "integratedTerminal", + "args": "run preset/agents/basic/main_orchestrator.yaml preset/agents/basic/webui.yaml preset/agents/markitdown_agents.yaml preset/agents/mermaid_agents.yaml preset/agents/web_agents.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -57,9 +81,9 @@ "name": "SAM (a2a-proxy)", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/orchestrator_example.yaml examples/gateways/webui_gateway_example.yaml examples/agents/test_agent_example.yaml examples/a2a_proxy_example.yaml", + "args": "run examples/agents/orchestrator_example.yaml examples/gateways/webui_gateway_example.yaml examples/agents/test_agent_example.yaml examples/a2a_proxy_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -69,18 +93,18 @@ "name": "SAM (test with Slack)", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/gateways/slack_gateway_example.yaml examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/gateways/webui_gateway_example.yaml examples/agents/test_agent_example.yaml examples/agents/a2a_multimodal_example.yaml", + "args": "run examples/gateways/slack_gateway_example.yaml examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/gateways/webui_gateway_example.yaml examples/agents/test_agent_example.yaml examples/agents/a2a_multimodal_example.yaml", "justMyCode": false, }, { "name": "SAM (simple)", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/orchestrator_example.yaml examples/gateways/webui_gateway_example.yaml examples/agents/test_agent_example.yaml examples/services/platform_service_example.yaml", + "args": "run examples/agents/orchestrator_example.yaml examples/gateways/webui_gateway_example.yaml examples/agents/test_agent_example.yaml examples/services/platform_service_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -90,9 +114,9 @@ "name": "SAM (with REST)", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/gateways/webui_gateway_example.yaml examples/gateways/rest_gateway_example.yaml examples/agents/test_agent_example.yaml examples/agents/a2a_multimodal_example.yaml", + "args": "run examples/agents/orchestrator_example.yaml examples/agents/a2a_agents_example.yaml examples/gateways/webui_gateway_example.yaml examples/gateways/rest_gateway_example.yaml examples/agents/test_agent_example.yaml examples/agents/a2a_multimodal_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -102,9 +126,9 @@ "name": "Multimodal Agent (With UI)", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/a2a_multimodal_example.yaml examples/gateways/webui_gateway_example.yaml ", + "args": "run examples/agents/a2a_multimodal_example.yaml examples/gateways/webui_gateway_example.yaml ", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -114,9 +138,9 @@ "name": "Orchestrator", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/orchestrator_example.yaml", + "args": "run examples/agents/orchestrator_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -126,9 +150,9 @@ "name": "Agents", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/a2a_agents_example.yaml", + "args": "run examples/agents/a2a_agents_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -138,9 +162,9 @@ "name": "MCP examples", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/a2a_mcp_example.yaml", + "args": "run examples/agents/a2a_mcp_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -150,9 +174,9 @@ "name": "Slack", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/gateways/slack_gateway_example.yaml", + "args": "run examples/gateways/slack_gateway_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -162,9 +186,9 @@ "name": "Webhook Gateway", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/gateways/webhook_gateway_example.yaml", + "args": "run examples/gateways/webhook_gateway_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -174,9 +198,9 @@ "name": "WebUI", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/gateways/webui_gateway_example.yaml", + "args": "run examples/gateways/webui_gateway_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -186,9 +210,9 @@ "name": "Minimal", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/orchestrator_example.yaml examples/gateways/webui_gateway_example.yaml", + "args": "run examples/agents/orchestrator_example.yaml examples/gateways/webui_gateway_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -198,9 +222,9 @@ "name": "Playwrite", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/agents/a2a_mcp_example.yaml examples/gateways/webui_gateway_example.yaml", + "args": "run examples/agents/a2a_mcp_example.yaml examples/gateways/webui_gateway_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" @@ -210,9 +234,9 @@ "name": "EM Gateway", "type": "debugpy", "request": "launch", - "module": "solace_ai_connector.main", + "module": "cli.main", "console": "integratedTerminal", - "args": "--envfile .env examples/gateways/event_mesh_gateway_example.yaml examples/agents/a2a_multimodal_example.yaml examples/agents/orchestrator_example.yaml examples/gateways/webui_gateway_example.yaml", + "args": "run examples/gateways/event_mesh_gateway_example.yaml examples/agents/a2a_multimodal_example.yaml examples/agents/orchestrator_example.yaml examples/gateways/webui_gateway_example.yaml", "justMyCode": false, "env": { "LOGGING_CONFIG_PATH": "${workspaceFolder}/preset/logging_config.yaml" diff --git a/README.md b/README.md index 3f3ca669b..15196bf07 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,9 @@ To run Solace Agent Mesh locally, you'll need: - **OS**: MacOS, Linux, or Windows (with [WSL](https://learn.microsoft.com/en-us/windows/wsl/)) - **LLM API key** (any major provider or custom endpoint) +### 🎸 Vibe Coding +To quickly setup and customize your Agent Mesh, check out the [Vibe Coding Quickstart Guide](docs/docs/documentation/getting-started/vibe_coding.md). This guide walks you through the essential steps to get Solace Agent Mesh up and running with minimal effort. + ### 💻 Setup Steps #### 1. Create a directory for a new project diff --git a/cli/__init__.py b/cli/__init__.py index b8045f577..f7d8b107f 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -1 +1 @@ -__version__ = "1.11.3" +__version__ = "1.13.4" diff --git a/cli/commands/add_cmd/__init__.py b/cli/commands/add_cmd/__init__.py index 0ee41a325..e86c28286 100644 --- a/cli/commands/add_cmd/__init__.py +++ b/cli/commands/add_cmd/__init__.py @@ -1,15 +1,17 @@ import click from .agent_cmd import add_agent from .gateway_cmd import add_gateway +from .proxy_cmd import add_proxy @click.group(name="add") def add(): """ - Creates templates for agents or gateways. + Creates templates for agents, gateways, or proxies. """ pass add.add_command(add_agent, name="agent") add.add_command(add_gateway, name="gateway") +add.add_command(add_proxy, name="proxy") diff --git a/cli/commands/add_cmd/agent_cmd.py b/cli/commands/add_cmd/agent_cmd.py index 2a2ce4ff4..e27a75a81 100644 --- a/cli/commands/add_cmd/agent_cmd.py +++ b/cli/commands/add_cmd/agent_cmd.py @@ -5,7 +5,11 @@ import click import yaml -from config_portal.backend.common import AGENT_DEFAULTS, USE_DEFAULT_SHARED_ARTIFACT, USE_DEFAULT_SHARED_SESSION +from config_portal.backend.common import ( + AGENT_DEFAULTS, + USE_DEFAULT_SHARED_ARTIFACT, + USE_DEFAULT_SHARED_SESSION, +) from ...utils import ( ask_if_not_provided, @@ -143,6 +147,7 @@ def _write_agent_yaml_from_data( Dumper=yaml.SafeDumper, default_flow_style=False, indent=2, + sort_keys=False, ).strip() if "\n" in tools_replacement_value: diff --git a/cli/commands/add_cmd/proxy_cmd.py b/cli/commands/add_cmd/proxy_cmd.py new file mode 100644 index 000000000..effe67527 --- /dev/null +++ b/cli/commands/add_cmd/proxy_cmd.py @@ -0,0 +1,100 @@ +import sys +from pathlib import Path + +import click + +from ...utils import ( + get_formatted_names, + load_template, +) + + +def _write_proxy_yaml(proxy_name_input: str, project_root: Path) -> tuple[bool, str, str]: + """ + Writes the proxy YAML file based on proxy_template.yaml. + + Args: + proxy_name_input: Name provided by user + project_root: Project root directory + + Returns: + Tuple of (success, message, relative_file_path) + """ + agents_config_dir = project_root / "configs" / "agents" + agents_config_dir.mkdir(parents=True, exist_ok=True) + + formatted_names = get_formatted_names(proxy_name_input) + proxy_name_pascal = formatted_names["PASCAL_CASE_NAME"] + file_name_snake = formatted_names["SNAKE_CASE_NAME"] + + proxy_config_file_path = agents_config_dir / f"{file_name_snake}_proxy.yaml" + + try: + # Load template + template_content = load_template("proxy_template.yaml") + + # Replace placeholder + modified_content = template_content.replace("__PROXY_NAME__", proxy_name_pascal) + + # Write file + with open(proxy_config_file_path, "w", encoding="utf-8") as f: + f.write(modified_content) + + relative_file_path = str(proxy_config_file_path.relative_to(project_root)) + return ( + True, + f"Proxy configuration created: {relative_file_path}", + relative_file_path, + ) + except FileNotFoundError as e: + return ( + False, + f"Error: Template file 'proxy_template.yaml' not found: {e}", + "", + ) + except Exception as e: + import traceback + click.echo( + f"DEBUG: Error in _write_proxy_yaml: {e}\n{traceback.format_exc()}", + err=True, + ) + return ( + False, + f"Error creating proxy configuration file {proxy_config_file_path}: {e}", + "", + ) + + +@click.command(name="proxy") +@click.argument("name", required=False) +@click.option( + "--skip", + is_flag=True, + help="Skip interactive prompts (creates proxy with default template).", +) +def add_proxy(name: str, skip: bool = False): + """ + Creates a new A2A proxy configuration. + + NAME: Name of the proxy component to create (e.g., my-proxy). + """ + if not name: + click.echo( + click.style( + "Error: You must provide a proxy name.", + fg="red", + ), + err=True, + ) + return + + click.echo(f"Creating proxy configuration for '{name}'...") + + project_root = Path.cwd() + success, message, _ = _write_proxy_yaml(name, project_root) + + if success: + click.echo(click.style(message, fg="green")) + else: + click.echo(click.style(message, fg="red"), err=True) + sys.exit(1) diff --git a/cli/commands/tools_cmd.py b/cli/commands/tools_cmd.py new file mode 100644 index 000000000..2f699109d --- /dev/null +++ b/cli/commands/tools_cmd.py @@ -0,0 +1,315 @@ +import click +import json +from typing import Optional, List, Dict, Any +from collections import defaultdict + +# Import to trigger tool registration +import solace_agent_mesh.agent.tools # noqa: F401 +from solace_agent_mesh.agent.tools.registry import tool_registry +from cli.utils import error_exit + + +def format_parameter_schema(schema) -> str: + """ + Format the parameter schema into a readable string. + + Args: + schema: A google.genai.types.Schema object + + Returns: + Formatted string representation of parameters + """ + if not schema or not hasattr(schema, 'properties') or not schema.properties: + return " No parameters" + + lines = [] + required = schema.required if hasattr(schema, 'required') else [] + + for prop_name, prop_schema in schema.properties.items(): + is_required = prop_name in required + req_str = "required" if is_required else "optional" + type_str = getattr(prop_schema, 'type', 'unknown') + desc = getattr(prop_schema, 'description', '') + lines.append(f" - {prop_name} ({type_str}, {req_str}): {desc}") + + return "\n".join(lines) + + +def format_tool_table_brief(tools: List) -> None: + """ + Format tools as a brief list and echo to console. + + Groups tools by category and displays only names and descriptions. + + Args: + tools: List of BuiltinTool objects + """ + if not tools: + click.echo("No tools found.") + return + + # Group tools by category + tools_by_category = defaultdict(list) + for tool in tools: + tools_by_category[tool.category].append(tool) + + # Sort categories alphabetically + sorted_categories = sorted(tools_by_category.keys()) + + total_tools = len(tools) + + for category in sorted_categories: + category_tools = sorted(tools_by_category[category], key=lambda t: t.name) + + # Get category metadata from first tool in category + first_tool = category_tools[0] + category_name = first_tool.category_name or category + + # Display category header + click.echo() + click.echo(click.style(f"═══ {category_name} ═══", bold=True, fg='cyan')) + click.echo() + + # Display tools in category (brief format) + for tool in category_tools: + click.echo(f" • {click.style(tool.name, bold=True, fg='green')}") + # Wrap description at 70 characters + desc_words = tool.description.split() + lines = [] + current_line = " " + for word in desc_words: + if len(current_line) + len(word) + 1 <= 74: + current_line += (" " if len(current_line) > 4 else "") + word + else: + lines.append(current_line) + current_line = " " + word + if current_line.strip(): + lines.append(current_line) + for line in lines: + click.echo(line) + click.echo() + + # Display summary + click.echo(click.style(f"Total: {total_tools} tool{'s' if total_tools != 1 else ''}", bold=True, fg='blue')) + + +def format_tool_table(tools: List) -> None: + """ + Format tools as a detailed table and echo to console. + + Groups tools by category and displays detailed information for each tool. + + Args: + tools: List of BuiltinTool objects + """ + if not tools: + click.echo("No tools found.") + return + + # Group tools by category + tools_by_category = defaultdict(list) + for tool in tools: + tools_by_category[tool.category].append(tool) + + # Sort categories alphabetically + sorted_categories = sorted(tools_by_category.keys()) + + total_tools = len(tools) + + for category in sorted_categories: + category_tools = sorted(tools_by_category[category], key=lambda t: t.name) + + # Get category metadata from first tool in category + first_tool = category_tools[0] + category_name = first_tool.category_name or category + category_desc = first_tool.category_description or "" + + # Display category header + header_width = 60 + click.echo() + click.echo("╭" + "─" * (header_width - 2) + "╮") + click.echo("│ " + click.style(category_name, bold=True, fg='cyan') + + " " * (header_width - len(category_name) - 3) + "│") + if category_desc: + # Wrap description if needed + desc_lines = [] + current_line = "" + for word in category_desc.split(): + if len(current_line) + len(word) + 1 <= header_width - 4: + current_line += (" " if current_line else "") + word + else: + desc_lines.append(current_line) + current_line = word + if current_line: + desc_lines.append(current_line) + + for desc_line in desc_lines: + click.echo("│ " + desc_line + " " * (header_width - len(desc_line) - 3) + "│") + click.echo("╰" + "─" * (header_width - 2) + "╯") + click.echo() + + # Display tools in category + for tool in category_tools: + click.echo(click.style(f"Tool: {tool.name}", bold=True, fg='green')) + click.echo(f"Description: {tool.description}") + + # Format and display parameters + click.echo("Parameters:") + params_str = format_parameter_schema(tool.parameters) + click.echo(params_str) + + # Display required scopes + if tool.required_scopes: + scopes_str = ", ".join(tool.required_scopes) + click.echo(f"Required Scopes: {click.style(scopes_str, fg='yellow')}") + else: + click.echo("Required Scopes: None") + + click.echo() # Blank line between tools + + # Display summary + click.echo(click.style(f"Total: {total_tools} tool{'s' if total_tools != 1 else ''}", + bold=True, fg='blue')) + + +def tools_to_json(tools: List, detailed: bool = False) -> str: + """ + Convert tools list to JSON format. + + Args: + tools: List of BuiltinTool objects + detailed: If True, include parameters and all metadata. If False, only name and description. + + Returns: + JSON string representation of tools + """ + result = [] + + for tool in tools: + if detailed: + # Convert Schema to dict if possible + try: + if hasattr(tool.parameters, 'model_dump'): + params_dict = tool.parameters.model_dump() + elif hasattr(tool.parameters, 'to_dict'): + params_dict = tool.parameters.to_dict() + else: + # Fallback: manually construct dict from Schema + params_dict = { + "type": getattr(tool.parameters, 'type', None), + "properties": {}, + "required": getattr(tool.parameters, 'required', []) + } + if hasattr(tool.parameters, 'properties') and tool.parameters.properties: + for prop_name, prop_schema in tool.parameters.properties.items(): + params_dict["properties"][prop_name] = { + "type": getattr(prop_schema, 'type', None), + "description": getattr(prop_schema, 'description', '') + } + except Exception: + params_dict = {"error": "Could not serialize parameters"} + + tool_dict = { + "name": tool.name, + "description": tool.description, + "category": tool.category, + "category_name": tool.category_name, + "category_description": tool.category_description, + "required_scopes": tool.required_scopes, + "parameters": params_dict, + "examples": tool.examples, + "raw_string_args": tool.raw_string_args + } + else: + # Brief format: only name and description + tool_dict = { + "name": tool.name, + "description": tool.description, + "category": tool.category, + "category_name": tool.category_name + } + + result.append(tool_dict) + + return json.dumps(result, indent=2) + + +@click.group("tools") +def tools(): + """Manage and explore SAM built-in tools.""" + pass + + +@tools.command("list") +@click.option( + "--category", "-c", + type=str, + default=None, + help="Filter tools by category (e.g., 'artifact_management', 'data_analysis')" +) +@click.option( + "--detailed", "-d", + is_flag=True, + help="Show detailed information including parameters and required scopes" +) +@click.option( + "--json", "output_json", + is_flag=True, + help="Output in JSON format instead of pretty table" +) +def list_tools(category: Optional[str], detailed: bool, output_json: bool): + """ + List all built-in tools available in Solace Agent Mesh. + + By default, shows brief information with tool names and descriptions. + Use --detailed flag to see parameters and required scopes. + + Examples: + + # List all tools (brief) + sam tools list + + # List with full details + sam tools list --detailed + + # Filter by category + sam tools list --category artifact_management + + # Detailed view with category filter + sam tools list -c web --detailed + + # Output as JSON + sam tools list --json + + # Filter and output as JSON + sam tools list -c web --json + """ + # Fetch tools from registry + if category: + tools_list = tool_registry.get_tools_by_category(category) + if not tools_list: + # Get all categories to show valid options + all_tools = tool_registry.get_all_tools() + if not all_tools: + error_exit("No tools are registered in the tool registry.") + + categories = sorted(set(t.category for t in all_tools)) + error_exit( + f"No tools found for category '{category}'.\n" + f"Valid categories: {', '.join(categories)}" + ) + else: + tools_list = tool_registry.get_all_tools() + if not tools_list: + error_exit("No tools are registered in the tool registry.") + + # Output based on format preference + if output_json: + json_output = tools_to_json(tools_list, detailed=detailed) + click.echo(json_output) + else: + # Use detailed format only if --detailed flag is provided + if detailed: + format_tool_table(tools_list) + else: + format_tool_table_brief(tools_list) diff --git a/cli/main.py b/cli/main.py index dd3e06499..1f7bdc631 100644 --- a/cli/main.py +++ b/cli/main.py @@ -18,6 +18,7 @@ from cli.commands.plugin_cmd import plugin from cli.commands.eval_cmd import eval_cmd from cli.commands.docs_cmd import docs +from cli.commands.tools_cmd import tools @click.group(context_settings=dict(help_option_names=['-h', '--help'])) @@ -42,6 +43,7 @@ def cli(): cli.add_command(plugin) cli.add_command(eval_cmd) cli.add_command(docs) +cli.add_command(tools) def main(): diff --git a/client/webui/frontend/.storybook/vitest.setup.ts b/client/webui/frontend/.storybook/vitest.setup.ts index e775d5395..3feee4deb 100644 --- a/client/webui/frontend/.storybook/vitest.setup.ts +++ b/client/webui/frontend/.storybook/vitest.setup.ts @@ -1,6 +1,22 @@ import { setProjectAnnotations } from "@storybook/react-vite"; import * as projectAnnotations from "./preview"; +// Official workaround for "TypeError: window.matchMedia is not a function" +// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: any) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, // deprecated + removeListener: () => {}, // deprecated + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => {}, + }), +}); + // This is an important step to apply the right configuration when testing your stories. // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations setProjectAnnotations([projectAnnotations]); diff --git a/client/webui/frontend/package-lock.json b/client/webui/frontend/package-lock.json index a747f068e..fb9925705 100644 --- a/client/webui/frontend/package-lock.json +++ b/client/webui/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@SolaceLabs/solace-agent-mesh-ui", - "version": "1.24.2", + "version": "1.31.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@SolaceLabs/solace-agent-mesh-ui", - "version": "1.24.2", + "version": "1.31.2", "license": "Apache-2.0", "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -22,9 +22,11 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", + "@stepperize/react": "^5.1.9", "@tailwindcss/vite": "^4.1.10", + "@tanstack/react-query": "5.90.16", "@tanstack/react-table": "^8.21.3", - "@xyflow/react": "^12.6.4", + "@use-gesture/react": "^10.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.2.6", @@ -34,13 +36,14 @@ "jszip": "^3.10.1", "lucide-react": "^0.511.0", "marked": "^15.0.12", + "mermaid": "^11.12.2", "react": "19.0.0", "react-dom": "19.0.0", "react-hook-form": "^7.65.0", "react-intersection-observer": "^9.16.0", "react-json-view-lite": "^2.4.1", "react-resizable-panels": "^3.0.3", - "react-router-dom": "7.9.3", + "react-router-dom": "7.12.0", "tailwind-merge": "^3.3.0", "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "^4.1.10", @@ -50,10 +53,14 @@ "devDependencies": { "@a2a-js/sdk": "^0.3.2", "@eslint/js": "^9.25.0", - "@storybook/addon-vitest": "^10.0.7", - "@storybook/react-vite": "^10.0.7", + "@storybook/addon-vitest": "^10.1.10", + "@storybook/react": "^10.1.8", + "@storybook/react-vite": "^10.1.10", "@tailwindcss/typography": "^0.5.16", + "@tanstack/eslint-plugin-query": "5.91.2", "@testing-library/cypress": "^10.0.3", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/js-yaml": "^4.0.9", "@types/node": "^22.15.29", "@types/react": "19.0.0", @@ -65,8 +72,9 @@ "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", - "eslint-plugin-storybook": "^10.0.7", + "eslint-plugin-storybook": "^10.1.10", "globals": "^16.0.0", + "jsdom": "^27.0.1", "lint-staged": "^16.2.3", "mocha-junit-reporter": "^2.2.1", "msw": "^2.12.3", @@ -74,7 +82,7 @@ "playwright": "^1.56.1", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.13", - "storybook": "^10.0.7", + "storybook": "^10.1.10", "tw-animate-css": "^1.3.3", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", @@ -103,7 +111,7 @@ "@types/react-dom": "19.0.0", "react": "19.0.0", "react-dom": "19.0.0", - "react-router-dom": "7.9.3" + "react-router-dom": "7.12.0" } }, "node_modules/@a2a-js/sdk": { @@ -152,6 +160,79 @@ "node": ">=6.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/install-pkg/node_modules/tinyexec": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "dev": true, @@ -286,8 +367,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -316,8 +395,6 @@ }, "node_modules/@babel/parser": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -398,8 +475,6 @@ }, "node_modules/@babel/types": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "license": "MIT", "dependencies": { @@ -412,14 +487,188 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.1", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast/node_modules/lodash-es": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "license": "Apache-2.0" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz", + "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@cypress/request": { "version": "3.0.9", "dev": true, @@ -536,7 +785,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -657,8 +908,6 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -671,8 +920,6 @@ }, "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -778,10 +1025,21 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", "dev": true, "license": "MIT", "engines": { @@ -790,8 +1048,6 @@ }, "node_modules/@inquirer/confirm": { "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "dev": true, "license": "MIT", "dependencies": { @@ -812,8 +1068,6 @@ }, "node_modules/@inquirer/core": { "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "dev": true, "license": "MIT", "dependencies": { @@ -840,8 +1094,6 @@ }, "node_modules/@inquirer/core/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -850,15 +1102,11 @@ }, "node_modules/@inquirer/core/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/@inquirer/core/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -867,8 +1115,6 @@ }, "node_modules/@inquirer/core/node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -882,8 +1128,6 @@ }, "node_modules/@inquirer/core/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -895,8 +1139,6 @@ }, "node_modules/@inquirer/core/node_modules/wrap-ansi": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, "license": "MIT", "dependencies": { @@ -910,8 +1152,6 @@ }, "node_modules/@inquirer/figures": { "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", "dev": true, "license": "MIT", "engines": { @@ -920,8 +1160,6 @@ }, "node_modules/@inquirer/type": { "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", "dev": true, "license": "MIT", "engines": { @@ -936,8 +1174,33 @@ } } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, "license": "ISC", "dependencies": { @@ -954,6 +1217,8 @@ }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -965,11 +1230,15 @@ }, "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { @@ -986,6 +1255,8 @@ }, "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1011,12 +1282,13 @@ } }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { - "version": "0.6.1", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.3.tgz", + "integrity": "sha512-9TGZuAX+liGkNKkwuo3FYJu7gHWT0vkBcf7GkOe7s7fmC19XwH/4u5u7sDIFrMooe558ORcmuBvBz7Ur5PlbHw==", "dev": true, "license": "MIT", "dependencies": { - "glob": "^10.0.0", - "magic-string": "^0.30.0", + "glob": "^11.1.0", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { @@ -1057,24 +1329,25 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mermaid-js/parser": { + "version": "0.6.3", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", - "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1123,15 +1396,11 @@ }, "node_modules/@open-draft/deferred-promise": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", "dev": true, "license": "MIT" }, "node_modules/@open-draft/logger": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1141,24 +1410,11 @@ }, "node_modules/@open-draft/until": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "dev": true, "license": "MIT" }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, "license": "MIT" }, @@ -2293,6 +2549,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2306,6 +2563,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2317,6 +2575,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2330,6 +2589,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2343,6 +2603,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2356,6 +2617,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2369,6 +2631,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2382,6 +2645,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2408,6 +2672,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2421,6 +2686,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2434,6 +2700,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2447,6 +2714,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2460,6 +2728,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2473,6 +2742,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2499,6 +2769,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2512,6 +2783,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2525,6 +2797,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2538,6 +2811,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2546,8 +2820,6 @@ }, "node_modules/@standard-schema/spec": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "dev": true, "license": "MIT" }, @@ -2555,17 +2827,36 @@ "version": "0.3.0", "license": "MIT" }, + "node_modules/@stepperize/core": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@stepperize/core/-/core-1.2.7.tgz", + "integrity": "sha512-XiUwLZ0XRAfaDK6AzWVgqvI/BcrylyplhUXKO8vzgRw0FTmyMKHAAbQLDvU//ZJAqnmG2cSLZDSkcwLxU5zSYA==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.0.2" + } + }, + "node_modules/@stepperize/react": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@stepperize/react/-/react-5.1.9.tgz", + "integrity": "sha512-yBgw1I5Tx6/qZB4xTdVBaPGfTqH5aYS1WFB5vtR8+fwPeqd3YNuOnQ1pJM6w/xV/gvryuy31hbFw080lZc+/hw==", + "license": "MIT", + "dependencies": { + "@stepperize/core": "1.2.7" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@storybook/addon-vitest": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.0.7.tgz", - "integrity": "sha512-i6v/mAl+elrUxb+1f4NdnM17t/fg+KGJWL1U9quflXTd3KiLY0xJB4LwNP6yYo7Imc5NIO2fRkJbGvNqLBRe2Q==", + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.1.11.tgz", + "integrity": "sha512-YbZzeKO3v+Xr97/malT4DZIATkVZT5EHNYx3xzEfPVuk19dDETAVYXO+tzcqCQHsgdKQHkmd56vv8nN3J3/kvw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^1.6.0", - "prompts": "^2.4.0", - "ts-dedent": "^2.2.0" + "@storybook/icons": "^2.0.0" }, "funding": { "type": "opencollective", @@ -2575,7 +2866,7 @@ "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", - "storybook": "^10.0.7", + "storybook": "^10.1.11", "vitest": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { @@ -2594,13 +2885,14 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.0.7.tgz", - "integrity": "sha512-wk2TAoUY5+9t78GWVBndu9rEo9lo6Ec3SRrLT4VpIlcS2GPK+5f26UC2uvIBwOF/N7JrUUKq/zWDZ3m+do9QDg==", + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.11.tgz", + "integrity": "sha512-MMD09Ap7FyzDfWG961pkIMv/w684XXe1bBEi+wCEpHxvrgAd3j3A9w/Rqp9Am2uRDPCEdi1QgSzS3SGW3aGThQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.0.7", + "@storybook/csf-plugin": "10.1.11", + "@vitest/mocker": "3.2.4", "ts-dedent": "^2.0.0" }, "funding": { @@ -2608,14 +2900,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.0.7", + "storybook": "^10.1.11", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@storybook/csf-plugin": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.0.7.tgz", - "integrity": "sha512-YaYYlCyJBwxaMk7yREOdz+9MDSgxIYGdeJ9EIq/bUndmkoj9SRo1P9/0lC5dseWQoiGy4T3PbZiWruD8uM5m3g==", + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.1.11.tgz", + "integrity": "sha512-Ant0NhgqHKzQsseeVTSetZCuDHHs0W2HRkHt51Kg/sUl0T/sDtfVA+fWZT8nGzGZqYSFkxqYPWjauPmIhPtaRw==", "dev": true, "license": "MIT", "dependencies": { @@ -2628,7 +2920,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.0.7", + "storybook": "^10.1.11", "vite": "*", "webpack": "*" }, @@ -2653,28 +2945,26 @@ "license": "MIT" }, "node_modules/@storybook/icons": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz", - "integrity": "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", + "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.0.0" - }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@storybook/react": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.0.7.tgz", - "integrity": "sha512-1GSDIMo2GkdG55DhpIIFaAJv+QzmsRb36qWsKqfbtFjEhnqu5/3zqyys2dCIiHOG1Czba4SGsTS4cay3KDQJgA==", + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.1.11.tgz", + "integrity": "sha512-rmMGmEwBaM2YpB8oDk2moM0MNjNMqtwyoPPZxjyruY9WVhYca8EDPGKEdRzUlb4qZJsTgLi7VU4eqg6LD/mL3Q==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.0.7" + "@storybook/react-dom-shim": "10.1.11", + "react-docgen": "^8.0.2" }, "funding": { "type": "opencollective", @@ -2683,7 +2973,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.0.7", + "storybook": "^10.1.11", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -2693,9 +2983,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.0.7.tgz", - "integrity": "sha512-bp4OnMtZGwPJQDqNRi4K5iibLbZ2TZZMkWW7oSw5jjPFpGSreSjCe8LH9yj/lDnK8Ox9bGMCBFE5RV5XuML29w==", + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.1.11.tgz", + "integrity": "sha512-o8WPhRlZbORUWG9lAgDgJP0pi905VHJUFJr1Kp8980gHqtlemtnzjPxKy5vFwj6glNhAlK8SS8OOYzWP7hloTQ==", "dev": true, "license": "MIT", "funding": { @@ -2705,20 +2995,20 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.0.7" + "storybook": "^10.1.11" } }, "node_modules/@storybook/react-vite": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.0.7.tgz", - "integrity": "sha512-EAv2cwYkRctQNcPC1jLsZPm+C6RVk6t6axKrkc/+cFe/t5MnKG7oRf0c/6apWYi/cQv6kzNsFxMV2jj8r/VoBg==", + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.1.11.tgz", + "integrity": "sha512-qh1BCD25nIoiDfqwha+qBkl7pcG4WuzM+c8tsE63YEm8AFIbNKg5K8lVUoclF+4CpFz7IwBpWe61YUTDfp+91w==", "dev": true, "license": "MIT", "dependencies": { - "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1", + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.3", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.0.7", - "@storybook/react": "10.0.7", + "@storybook/builder-vite": "10.1.11", + "@storybook/react": "10.1.11", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", @@ -2732,7 +3022,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.0.7", + "storybook": "^10.1.11", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, @@ -2863,35 +3153,258 @@ "vite": "^5.2.0 || ^6 || ^7" } }, - "node_modules/@tanstack/react-table": { - "version": "8.21.3", + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.2.tgz", + "integrity": "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw==", + "dev": true, "license": "MIT", "dependencies": { - "@tanstack/table-core": "8.21.3" - }, - "engines": { - "node": ">=12" + "@typescript-eslint/utils": "^8.44.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "eslint": "^8.57.0 || ^9.0.0" } }, - "node_modules/@tanstack/table-core": { - "version": "8.21.3", + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/project-service": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", + "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", + "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.0", + "@typescript-eslint/types": "^8.53.0", + "debug": "^4.4.3" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", + "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", + "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/types": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", + "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.53.0", + "@typescript-eslint/tsconfig-utils": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", + "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", + "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/cypress": { "version": "10.1.0", "dev": true, @@ -2949,6 +3462,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "14.6.1", "dev": true, @@ -3012,10 +3553,84 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "license": "MIT" + }, "node_modules/@types/d3-drag": { "version": "3.0.7", "license": "MIT", @@ -3023,6 +3638,40 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "license": "MIT", @@ -3030,10 +3679,56 @@ "@types/d3-color": "*" } }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "license": "MIT" + }, "node_modules/@types/d3-selection": { "version": "3.0.11", "license": "MIT" }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "license": "MIT" + }, "node_modules/@types/d3-transition": { "version": "3.0.9", "license": "MIT", @@ -3061,6 +3756,11 @@ }, "node_modules/@types/estree": { "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", "license": "MIT" }, "node_modules/@types/js-yaml": { @@ -3075,7 +3775,7 @@ }, "node_modules/@types/node": { "version": "22.16.0", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3083,9 +3783,7 @@ }, "node_modules/@types/react": { "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.0.tgz", - "integrity": "sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -3093,9 +3791,7 @@ }, "node_modules/@types/react-dom": { "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.0.tgz", - "integrity": "sha512-1KfiQKsH1o00p9m5ag12axHQSb3FOU9H20UTrujVSkNhuCrRHiQWFqgEnTNK5ZNfnzZv8UWrnXVqCmCF9fgY3w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/react": "*" @@ -3118,8 +3814,6 @@ }, "node_modules/@types/statuses": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "dev": true, "license": "MIT" }, @@ -3379,6 +4073,20 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.6.0", "dev": true, @@ -3400,8 +4108,6 @@ }, "node_modules/@vitest/browser": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.8.tgz", - "integrity": "sha512-oG6QJAR0d7S5SDnIYZwjxCj/a5fhbp9ZE7GtMgZn+yCUf4CxtqbBV6aXyg0qmn8nbUWT+rGuXL2ZB6qDBUjv/A==", "dev": true, "license": "MIT", "dependencies": { @@ -3423,8 +4129,6 @@ }, "node_modules/@vitest/browser-playwright": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.8.tgz", - "integrity": "sha512-MUi0msIAPXcA2YAuVMcssrSYP/yylxLt347xyTC6+ODl0c4XQFs0d2AN3Pc3iTa0pxIGmogflUV6eogXpPbJeA==", "dev": true, "license": "MIT", "dependencies": { @@ -3447,8 +4151,6 @@ }, "node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz", - "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==", "dev": true, "license": "MIT", "dependencies": { @@ -3474,8 +4176,6 @@ }, "node_modules/@vitest/browser-playwright/node_modules/@vitest/spy": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz", - "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==", "dev": true, "license": "MIT", "funding": { @@ -3484,8 +4184,6 @@ }, "node_modules/@vitest/browser-playwright/node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -3494,8 +4192,6 @@ }, "node_modules/@vitest/browser-playwright/node_modules/tinyrainbow": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -3504,8 +4200,6 @@ }, "node_modules/@vitest/browser/node_modules/@vitest/mocker": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz", - "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==", "dev": true, "license": "MIT", "dependencies": { @@ -3531,8 +4225,6 @@ }, "node_modules/@vitest/browser/node_modules/@vitest/pretty-format": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", "dev": true, "license": "MIT", "dependencies": { @@ -3544,8 +4236,6 @@ }, "node_modules/@vitest/browser/node_modules/@vitest/spy": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz", - "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==", "dev": true, "license": "MIT", "funding": { @@ -3554,8 +4244,6 @@ }, "node_modules/@vitest/browser/node_modules/@vitest/utils": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", - "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", "dev": true, "license": "MIT", "dependencies": { @@ -3568,8 +4256,6 @@ }, "node_modules/@vitest/browser/node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -3578,8 +4264,6 @@ }, "node_modules/@vitest/browser/node_modules/tinyrainbow": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -3588,8 +4272,6 @@ }, "node_modules/@vitest/coverage-v8": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.8.tgz", - "integrity": "sha512-wQgmtW6FtPNn4lWUXi8ZSYLpOIb92j3QCujxX3sQ81NTfQ/ORnE0HtK7Kqf2+7J9jeveMGyGyc4NWc5qy3rC4A==", "dev": true, "license": "MIT", "dependencies": { @@ -3620,8 +4302,6 @@ }, "node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", "dev": true, "license": "MIT", "dependencies": { @@ -3633,8 +4313,6 @@ }, "node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", - "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", "dev": true, "license": "MIT", "dependencies": { @@ -3647,8 +4325,6 @@ }, "node_modules/@vitest/coverage-v8/node_modules/tinyrainbow": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -3672,6 +4348,8 @@ }, "node_modules/@vitest/mocker": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3697,6 +4375,8 @@ }, "node_modules/@vitest/mocker/node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -3716,8 +4396,6 @@ }, "node_modules/@vitest/runner": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.8.tgz", - "integrity": "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3730,8 +4408,6 @@ }, "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", "dev": true, "license": "MIT", "dependencies": { @@ -3743,8 +4419,6 @@ }, "node_modules/@vitest/runner/node_modules/@vitest/utils": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", - "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", "dev": true, "license": "MIT", "dependencies": { @@ -3757,8 +4431,6 @@ }, "node_modules/@vitest/runner/node_modules/tinyrainbow": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -3767,8 +4439,6 @@ }, "node_modules/@vitest/snapshot": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.8.tgz", - "integrity": "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw==", "dev": true, "license": "MIT", "dependencies": { @@ -3782,8 +4452,6 @@ }, "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", "dev": true, "license": "MIT", "dependencies": { @@ -3795,8 +4463,6 @@ }, "node_modules/@vitest/snapshot/node_modules/tinyrainbow": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -3827,37 +4493,8 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@xyflow/react": { - "version": "12.8.1", - "license": "MIT", - "dependencies": { - "@xyflow/system": "0.0.65", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@xyflow/system": { - "version": "0.0.65", - "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" - } - }, "node_modules/acorn": { "version": "8.15.0", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3874,6 +4511,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "dev": true, @@ -4026,8 +4673,6 @@ }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.8", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", - "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4038,8 +4683,6 @@ }, "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -4048,8 +4691,6 @@ }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true, "license": "MIT" }, @@ -4124,6 +4765,16 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/blob-util": { "version": "2.0.2", "dev": true, @@ -4154,12 +4805,6 @@ "node": ">=8" } }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/browserslist": { "version": "4.25.1", "dev": true, @@ -4214,12 +4859,28 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", + "node_modules/buffer-crc32": { + "version": "0.2.13", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "dev": true, "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, "engines": { - "node": "*" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cachedir": { @@ -4265,18 +4926,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "6.3.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001726", "dev": true, @@ -4355,21 +5004,32 @@ "node": ">= 0.8.0" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "dev": true, + "node_modules/chevrotain": { + "version": "11.0.3", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", "license": "MIT", - "peer": true, "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" + "lodash-es": "^4.17.21" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "chevrotain": "^11.0.0" } }, + "node_modules/chevrotain/node_modules/lodash-es": { + "version": "4.17.21", + "license": "MIT" + }, "node_modules/chownr": { "version": "3.0.0", "license": "BlueOak-1.0.0", @@ -4401,10 +5061,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/classcat": { - "version": "5.0.5", - "license": "MIT" - }, "node_modules/clean-stack": { "version": "2.2.0", "dev": true, @@ -4503,8 +5159,6 @@ }, "node_modules/cli-width": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, "license": "ISC", "engines": { @@ -4654,6 +5308,10 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "dev": true, @@ -4661,8 +5319,6 @@ }, "node_modules/cookie": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", "engines": { "node": ">=18" @@ -4672,6 +5328,13 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/cose-base": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "dev": true, @@ -4693,6 +5356,20 @@ "node": "*" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css.escape": { "version": "1.5.1", "dev": true, @@ -4709,9 +5386,35 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.1.3", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cypress": { @@ -4973,103 +5676,400 @@ "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cypress/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cypress/node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/cypress/node_modules/type-fest": { + "version": "0.21.3", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cypress/node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cytoscape": { + "version": "3.33.1", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/cypress/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "license": "MIT", + "node_modules/d3-force": { + "version": "3.0.0", + "license": "ISC", "dependencies": { - "ansi-regex": "^5.0.1" + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/cypress/node_modules/supports-color": { - "version": "8.1.1", - "dev": true, - "license": "MIT", + "node_modules/d3-format": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "license": "ISC", "dependencies": { - "has-flag": "^4.0.0" + "d3-array": "2.5.0 - 3" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=12" } }, - "node_modules/cypress/node_modules/type-fest": { - "version": "0.21.3", - "dev": true, - "license": "(MIT OR CC0-1.0)", + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "license": "ISC", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/cypress/node_modules/wrap-ansi": { - "version": "7.0.0", - "dev": true, - "license": "MIT", + "node_modules/d3-interpolate": { + "version": "3.0.1", + "license": "ISC", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "d3-color": "1 - 3" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=12" } }, - "node_modules/d3-color": { + "node_modules/d3-path": { "version": "3.1.0", "license": "ISC", "engines": { "node": ">=12" } }, - "node_modules/d3-dispatch": { + "node_modules/d3-polygon": { "version": "3.0.1", "license": "ISC", "engines": { "node": ">=12" } }, - "node_modules/d3-drag": { - "version": "3.0.0", + "node_modules/d3-quadtree": { + "version": "3.0.1", "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, "engines": { "node": ">=12" } }, - "node_modules/d3-ease": { + "node_modules/d3-random": { "version": "3.0.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, "engines": { "node": ">=12" } }, - "node_modules/d3-interpolate": { - "version": "3.0.1", + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", "license": "ISC", "dependencies": { - "d3-color": "1 - 3" + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" }, "engines": { "node": ">=12" @@ -5082,6 +6082,36 @@ "node": ">=12" } }, + "node_modules/d3-shape": { + "version": "3.2.0", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "3.0.1", "license": "ISC", @@ -5120,6 +6150,14 @@ "node": ">=12" } }, + "node_modules/dagre-d3-es": { + "version": "7.0.13", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/dashdash": { "version": "1.14.1", "dev": true, @@ -5131,15 +6169,26 @@ "node": ">=0.10" } }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/dayjs": { "version": "1.11.18", - "dev": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -5154,17 +6203,12 @@ } } }, - "node_modules/decamelize": { - "version": "4.0.0", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, "node_modules/deep-eql": { "version": "5.0.2", @@ -5179,6 +6223,56 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", + "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delaunator": { + "version": "5.0.1", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "dev": true, @@ -5206,15 +6300,6 @@ "version": "1.1.0", "license": "MIT" }, - "node_modules/diff": { - "version": "7.0.0", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/doctrine": { "version": "3.0.0", "dev": true, @@ -5310,6 +6395,8 @@ }, "node_modules/eastasianwidth": { "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, "license": "MIT" }, @@ -5334,8 +6421,6 @@ }, "node_modules/empathic": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", "dev": true, "license": "MIT", "engines": { @@ -5431,8 +6516,6 @@ }, "node_modules/es-module-lexer": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -5463,6 +6546,7 @@ }, "node_modules/esbuild": { "version": "0.25.5", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5597,9 +6681,9 @@ } }, "node_modules/eslint-plugin-storybook": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.0.7.tgz", - "integrity": "sha512-qOQq9KdT1jsBgT3qsxUH2n67aj1WR8D1XCoER8Q6yuVlS5TimNwk1mZeWkXVf/o4RQQT6flT2y5cG2gPLZPvJA==", + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.1.11.tgz", + "integrity": "sha512-mbq2r2kK5+AcLl0XDJ3to91JOgzCbHOqj+J3n+FRw6drk+M1boRqMShSoMMm0HdzXPLmlr7iur+qJ5ZuhH6ayQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5607,7 +6691,7 @@ }, "peerDependencies": { "eslint": ">=8", - "storybook": "^10.0.7" + "storybook": "^10.1.11" } }, "node_modules/eslint-scope": { @@ -5766,8 +6850,6 @@ }, "node_modules/expect-type": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5922,15 +7004,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat": { - "version": "5.0.2", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "bin": { - "flat": "cli.js" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "dev": true, @@ -5950,6 +7023,8 @@ }, "node_modules/foreground-child": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { @@ -6002,6 +7077,7 @@ }, "node_modules/fsevents": { "version": "2.3.3", + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6119,22 +7195,25 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -6150,23 +7229,17 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6219,14 +7292,16 @@ }, "node_modules/graphql": { "version": "16.12.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", - "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "dev": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -6286,19 +7361,8 @@ "node": ">= 0.4" } }, - "node_modules/he": { - "version": "1.2.0", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "he": "bin/he" - } - }, "node_modules/headers-polyfill": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", "dev": true, "license": "MIT" }, @@ -6310,10 +7374,21 @@ "htmlparser2": "10.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, @@ -6353,6 +7428,20 @@ "entities": "^6.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-signature": { "version": "1.4.0", "dev": true, @@ -6366,12 +7455,36 @@ "node": ">=0.10" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "1.1.1", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=8.12.0" + "node": ">=8.12.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" } }, "node_modules/ieee754": { @@ -6403,8 +7516,6 @@ }, "node_modules/immediate": { "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, "node_modules/import-fresh": { @@ -6440,8 +7551,6 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ini": { @@ -6456,6 +7565,13 @@ "version": "0.2.4", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "dev": true, @@ -6475,6 +7591,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "dev": true, @@ -6508,6 +7640,25 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-installed-globally": { "version": "0.4.0", "dev": true, @@ -6525,8 +7676,6 @@ }, "node_modules/is-node-process": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", "dev": true, "license": "MIT" }, @@ -6546,14 +7695,12 @@ "node": ">=8" } }, - "node_modules/is-plain-obj": { - "version": "2.1.0", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } + "license": "MIT" }, "node_modules/is-stream": { "version": "2.0.1", @@ -6582,10 +7729,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, "node_modules/isexe": { @@ -6600,8 +7761,6 @@ }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6610,8 +7769,6 @@ }, "node_modules/istanbul-lib-report": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6625,8 +7782,6 @@ }, "node_modules/istanbul-lib-source-maps": { "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6640,8 +7795,6 @@ }, "node_modules/istanbul-reports": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6653,17 +7806,19 @@ } }, "node_modules/jackspeak": { - "version": "3.4.3", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jiti": { @@ -6680,8 +7835,6 @@ }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6695,6 +7848,79 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/jsdom/node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/jsesc": { "version": "3.1.0", "dev": true, @@ -6780,8 +8006,6 @@ }, "node_modules/jszip": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", "license": "(MIT OR GPL-3.0-or-later)", "dependencies": { "lie": "~3.3.0", @@ -6790,6 +8014,27 @@ "setimmediate": "^1.0.5" } }, + "node_modules/katex": { + "version": "0.16.27", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -6798,16 +8043,27 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, + "node_modules/khroma": { + "version": "2.1.0" + }, + "node_modules/langium": { + "version": "3.3.1", "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "license": "MIT" + }, "node_modules/lazy-ass": { "version": "1.6.0", "dev": true, @@ -6830,8 +8086,6 @@ }, "node_modules/lie": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "license": "MIT", "dependencies": { "immediate": "~3.0.5" @@ -6984,6 +8238,10 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.22", + "license": "MIT" + }, "node_modules/lodash.castarray": { "version": "4.4.0", "dev": true, @@ -7067,8 +8325,6 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -7076,8 +8332,6 @@ }, "node_modules/magicast": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", "dev": true, "license": "MIT", "dependencies": { @@ -7088,8 +8342,6 @@ }, "node_modules/make-dir": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { @@ -7104,8 +8356,6 @@ }, "node_modules/make-dir/node_modules/semver": { "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7143,6 +8393,13 @@ "is-buffer": "~1.1.6" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge-stream": { "version": "2.0.0", "dev": true, @@ -7156,6 +8413,53 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.12.2", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.1", + "@mermaid-js/parser": "^0.6.3", + "@types/d3": "^7.4.3", + "cytoscape": "^3.29.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.18", + "dompurify": "^3.2.5", + "katex": "^0.16.22", + "khroma": "^2.1.0", + "lodash-es": "^4.17.21", + "marked": "^16.2.1", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "11.1.0", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/micromatch": { "version": "4.0.8", "dev": true, @@ -7263,40 +8567,14 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha": { - "version": "11.7.4", - "dev": true, + "node_modules/mlly": { + "version": "1.8.0", "license": "MIT", - "peer": true, "dependencies": { - "browser-stdout": "^1.3.1", - "chokidar": "^4.0.1", - "debug": "^4.3.5", - "diff": "^7.0.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", - "ms": "^2.1.3", - "picocolors": "^1.1.1", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^9.2.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" } }, "node_modules/mocha-junit-reporter": { @@ -7333,49 +8611,8 @@ "node": ">=8" } }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/mocha/node_modules/minimatch": { - "version": "9.0.5", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/mrmime": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", "engines": { @@ -7389,8 +8626,6 @@ }, "node_modules/msw": { "version": "2.12.3", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.3.tgz", - "integrity": "sha512-/5rpGC0eK8LlFqsHaBmL19/PVKxu/CCt8pO1vzp9X6SDLsRDh/Ccudkf3Ur5lyaKxJz9ndAx+LaThdv0ySqB6A==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7434,8 +8669,6 @@ }, "node_modules/msw-storybook-addon": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.6.tgz", - "integrity": "sha512-ExCwDbcJoM2V3iQU+fZNp+axVfNc7DWMRh4lyTXebDO8IbpUNYKGFUrA8UqaeWiRGKVuS7+fU+KXEa9b0OP6uA==", "dev": true, "license": "MIT", "dependencies": { @@ -7447,8 +8680,6 @@ }, "node_modules/msw/node_modules/tldts": { "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", "dev": true, "license": "MIT", "dependencies": { @@ -7460,15 +8691,11 @@ }, "node_modules/msw/node_modules/tldts-core": { "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", "dev": true, "license": "MIT" }, "node_modules/msw/node_modules/tough-cookie": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7480,8 +8707,6 @@ }, "node_modules/msw/node_modules/type-fest": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.2.0.tgz", - "integrity": "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -7496,8 +8721,6 @@ }, "node_modules/mute-stream": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, "license": "ISC", "engines": { @@ -7517,6 +8740,7 @@ }, "node_modules/nanoid": { "version": "3.3.11", + "dev": true, "funding": [ { "type": "github", @@ -7593,6 +8817,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -7616,8 +8859,6 @@ }, "node_modules/outvariant": { "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", "dev": true, "license": "MIT" }, @@ -7665,13 +8906,17 @@ }, "node_modules/package-json-from-dist": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "license": "MIT" + }, "node_modules/pako": { "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -7685,6 +8930,23 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -7707,37 +8969,39 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-to-regexp": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, "license": "MIT" }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/pathval": { @@ -7760,6 +9024,7 @@ }, "node_modules/picocolors": { "version": "1.1.1", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -7794,8 +9059,6 @@ }, "node_modules/pixelmatch": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", - "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", "dev": true, "license": "ISC", "dependencies": { @@ -7805,10 +9068,17 @@ "pixelmatch": "bin/pixelmatch" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/playwright": { "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -7826,8 +9096,6 @@ }, "node_modules/playwright-core": { "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7839,10 +9107,7 @@ }, "node_modules/playwright/node_modules/fsevents": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -7854,16 +9119,27 @@ }, "node_modules/pngjs": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.19.0" + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" } }, "node_modules/postcss": { "version": "8.5.6", + "dev": true, "funding": [ { "type": "opencollective", @@ -8057,24 +9333,8 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/proxy-from-env": { "version": "1.0.0", "dev": true, @@ -8130,15 +9390,6 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react": { "version": "19.0.0", "license": "MIT", @@ -8168,6 +9419,8 @@ }, "node_modules/react-docgen-typescript": { "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -8285,9 +9538,9 @@ } }, "node_modules/react-router": { - "version": "7.9.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", - "integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -8307,12 +9560,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz", - "integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", "license": "MIT", "dependencies": { - "react-router": "7.9.3" + "react-router": "7.12.0" }, "engines": { "node": ">=20.0.0" @@ -8344,8 +9597,6 @@ }, "node_modules/readable-stream": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -8359,23 +9610,8 @@ }, "node_modules/readable-stream/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/readdirp": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/recast": { "version": "0.23.11", "dev": true, @@ -8430,6 +9666,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "dev": true, @@ -8474,8 +9720,6 @@ }, "node_modules/rettime": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", - "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", "dev": true, "license": "MIT" }, @@ -8493,8 +9737,13 @@ "dev": true, "license": "MIT" }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.44.1", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -8537,6 +9786,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8550,12 +9800,43 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/roughjs": { + "version": "4.6.6", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "dev": true, @@ -8578,6 +9859,10 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.2", "dev": true, @@ -8607,9 +9892,21 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.25.0", "license": "MIT" @@ -8622,15 +9919,6 @@ "semver": "bin/semver.js" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -8639,8 +9927,6 @@ }, "node_modules/setimmediate": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, "node_modules/shebang-command": { @@ -8732,8 +10018,6 @@ }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, @@ -8750,8 +10034,6 @@ }, "node_modules/sirv": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dev": true, "license": "MIT", "dependencies": { @@ -8763,13 +10045,6 @@ "node": ">=18" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, "node_modules/slice-ansi": { "version": "7.1.2", "dev": true, @@ -8837,15 +10112,11 @@ }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/statuses": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", "engines": { @@ -8854,28 +10125,27 @@ }, "node_modules/std-env": { "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, "node_modules/storybook": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.0.7.tgz", - "integrity": "sha512-7smAu0o+kdm378Q2uIddk32pn0UdIbrtTVU+rXRVtTVTCrK/P2cCui2y4JH+Bl3NgEq1bbBQpCAF/HKrDjk2Qw==", + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.11.tgz", + "integrity": "sha512-pKP5jXJYM4OjvNklGuHKO53wOCAwfx79KvZyOWHoi9zXUH5WVMFUe/ZfWyxXG/GTcj0maRgHGUjq/0I43r0dDQ==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^1.6.0", + "@storybook/icons": "^2.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", "@vitest/spy": "3.2.4", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.6.2", + "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "bin": { @@ -8907,15 +10177,11 @@ }, "node_modules/strict-event-emitter": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", "dev": true, "license": "MIT" }, "node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -8923,8 +10189,6 @@ }, "node_modules/string_decoder/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, "node_modules/string-argv": { @@ -8953,6 +10217,8 @@ "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -8966,6 +10232,8 @@ }, "node_modules/string-width-cjs/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -8974,11 +10242,15 @@ }, "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -8987,6 +10259,8 @@ }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -9013,6 +10287,8 @@ "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -9024,6 +10300,8 @@ }, "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -9082,6 +10360,10 @@ "inline-style-parser": "0.2.4" } }, + "node_modules/stylis": { + "version": "4.3.6", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -9104,10 +10386,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "dev": true, "license": "MIT", "engines": { @@ -9185,22 +10472,17 @@ }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -9215,8 +10497,7 @@ }, "node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -9232,8 +10513,7 @@ }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -9295,8 +10575,6 @@ }, "node_modules/totalist": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "license": "MIT", "engines": { @@ -9314,6 +10592,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "dev": true, @@ -9323,7 +10614,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -9335,9 +10628,6 @@ }, "node_modules/ts-dedent": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", - "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.10" @@ -9436,9 +10726,13 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/ufo": { + "version": "1.6.1", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -9450,9 +10744,9 @@ } }, "node_modules/unplugin": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.10.tgz", - "integrity": "sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==", + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", "dev": true, "license": "MIT", "dependencies": { @@ -9480,8 +10774,6 @@ }, "node_modules/until-async": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", - "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", "dev": true, "license": "MIT", "funding": { @@ -9609,8 +10901,7 @@ }, "node_modules/vite": { "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -9683,6 +10974,7 @@ }, "node_modules/vite/node_modules/fdir": { "version": "6.4.6", + "dev": true, "license": "MIT", "peerDependencies": { "picomatch": "^3 || ^4" @@ -9695,6 +10987,7 @@ }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.2", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -9705,8 +10998,6 @@ }, "node_modules/vitest": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.8.tgz", - "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", "dev": true, "license": "MIT", "dependencies": { @@ -9783,8 +11074,6 @@ }, "node_modules/vitest/node_modules/@vitest/expect": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.8.tgz", - "integrity": "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA==", "dev": true, "license": "MIT", "dependencies": { @@ -9801,8 +11090,6 @@ }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz", - "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==", "dev": true, "license": "MIT", "dependencies": { @@ -9828,8 +11115,6 @@ }, "node_modules/vitest/node_modules/@vitest/pretty-format": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", "dev": true, "license": "MIT", "dependencies": { @@ -9841,8 +11126,6 @@ }, "node_modules/vitest/node_modules/@vitest/spy": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz", - "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==", "dev": true, "license": "MIT", "funding": { @@ -9851,8 +11134,6 @@ }, "node_modules/vitest/node_modules/@vitest/utils": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", - "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", "dev": true, "license": "MIT", "dependencies": { @@ -9865,8 +11146,6 @@ }, "node_modules/vitest/node_modules/chai": { "version": "6.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", - "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", "dev": true, "license": "MIT", "engines": { @@ -9875,8 +11154,6 @@ }, "node_modules/vitest/node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -9885,8 +11162,6 @@ }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -9898,14 +11173,72 @@ }, "node_modules/vitest/node_modules/tinyrainbow": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -9913,6 +11246,44 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -9929,8 +11300,6 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -9952,12 +11321,6 @@ "node": ">=0.10.0" } }, - "node_modules/workerpool": { - "version": "9.3.4", - "dev": true, - "license": "Apache-2.0", - "peer": true - }, "node_modules/wrap-ansi": { "version": "9.0.2", "dev": true, @@ -9977,6 +11340,8 @@ "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { @@ -9993,6 +11358,8 @@ }, "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -10001,11 +11368,15 @@ }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -10014,6 +11385,8 @@ }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -10027,6 +11400,8 @@ }, "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -10088,11 +11463,44 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml": { "version": "1.0.1", "dev": true, "license": "MIT" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "dev": true, @@ -10108,7 +11516,7 @@ }, "node_modules/yaml": { "version": "2.8.1", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -10142,21 +11550,6 @@ "node": ">=12" } }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/yargs/node_modules/ansi-regex": { "version": "5.0.1", "dev": true, @@ -10224,8 +11617,6 @@ }, "node_modules/yoctocolors-cjs": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", "dev": true, "license": "MIT", "engines": { @@ -10241,32 +11632,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zustand": { - "version": "4.5.7", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } } } } diff --git a/client/webui/frontend/package.json b/client/webui/frontend/package.json index e578f7ce2..d60395079 100644 --- a/client/webui/frontend/package.json +++ b/client/webui/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@SolaceLabs/solace-agent-mesh-ui", - "version": "1.24.2", + "version": "1.31.2", "description": "Solace Agent Mesh UI components - React library for building agent communication interfaces", "author": "SolaceLabs ", "license": "Apache-2.0", @@ -53,7 +53,8 @@ "preview": "vite preview", "storybook": "storybook dev -p 6006", "test:storybook": "vitest --project=storybook", - "ci:storybook": "CI=true npm run test:storybook" + "test:unit": "vitest --project=unit", + "ci:storybook": "CI=true vitest --project=unit --project=storybook" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -78,9 +79,11 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", + "@stepperize/react": "^5.1.9", "@tailwindcss/vite": "^4.1.10", + "@tanstack/react-query": "5.90.16", "@tanstack/react-table": "^8.21.3", - "@xyflow/react": "^12.6.4", + "@use-gesture/react": "^10.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.2.6", @@ -90,13 +93,14 @@ "jszip": "^3.10.1", "lucide-react": "^0.511.0", "marked": "^15.0.12", + "mermaid": "^11.12.2", "react": "19.0.0", "react-dom": "19.0.0", "react-hook-form": "^7.65.0", "react-intersection-observer": "^9.16.0", "react-json-view-lite": "^2.4.1", "react-resizable-panels": "^3.0.3", - "react-router-dom": "7.9.3", + "react-router-dom": "7.12.0", "tailwind-merge": "^3.3.0", "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "^4.1.10", @@ -108,15 +112,19 @@ "@types/react-dom": "19.0.0", "react": "19.0.0", "react-dom": "19.0.0", - "react-router-dom": "7.9.3" + "react-router-dom": "7.12.0" }, "devDependencies": { "@a2a-js/sdk": "^0.3.2", "@eslint/js": "^9.25.0", - "@storybook/addon-vitest": "^10.0.7", - "@storybook/react-vite": "^10.0.7", + "@storybook/addon-vitest": "^10.1.10", + "@storybook/react": "^10.1.8", + "@storybook/react-vite": "^10.1.10", "@tailwindcss/typography": "^0.5.16", + "@tanstack/eslint-plugin-query": "5.91.2", "@testing-library/cypress": "^10.0.3", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/js-yaml": "^4.0.9", "@types/node": "^22.15.29", "@types/react": "19.0.0", @@ -128,8 +136,9 @@ "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", - "eslint-plugin-storybook": "^10.0.7", + "eslint-plugin-storybook": "^10.1.10", "globals": "^16.0.0", + "jsdom": "^27.0.1", "lint-staged": "^16.2.3", "mocha-junit-reporter": "^2.2.1", "msw": "^2.12.3", @@ -137,7 +146,7 @@ "playwright": "^1.56.1", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.13", - "storybook": "^10.0.7", + "storybook": "^10.1.10", "tw-animate-css": "^1.3.3", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", diff --git a/client/webui/frontend/src/App.tsx b/client/webui/frontend/src/App.tsx index e957fbceb..43d5a3303 100644 --- a/client/webui/frontend/src/App.tsx +++ b/client/webui/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { RouterProvider } from "react-router-dom"; import { TextSelectionProvider } from "@/lib/components/chat/selection"; -import { AuthProvider, ConfigProvider, CsrfProvider, ProjectProvider, TaskProvider, ThemeProvider, AudioSettingsProvider } from "@/lib/providers"; +import { AuthProvider, ConfigProvider, CsrfProvider, ProjectProvider, TaskProvider, ThemeProvider, AudioSettingsProvider, QueryProvider } from "@/lib/providers"; import { createRouter } from "./router"; @@ -11,23 +11,25 @@ function AppContent() { function App() { return ( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ); } diff --git a/client/webui/frontend/src/lib/api/index.ts b/client/webui/frontend/src/lib/api/index.ts index f23e1b1be..26007e32b 100644 --- a/client/webui/frontend/src/lib/api/index.ts +++ b/client/webui/frontend/src/lib/api/index.ts @@ -1 +1,2 @@ export { api } from "./client"; +export * from "./projects"; diff --git a/client/webui/frontend/src/lib/api/projects/hooks.ts b/client/webui/frontend/src/lib/api/projects/hooks.ts new file mode 100644 index 000000000..bb5ff54ef --- /dev/null +++ b/client/webui/frontend/src/lib/api/projects/hooks.ts @@ -0,0 +1,134 @@ +/** + * ⚠️ WARNING: THESE HOOKS ARE NOT YET READY FOR USE ⚠️ + * + * This file contains React Query hooks that are still under development and testing. + * DO NOT import or use these hooks in your components yet. + * + * Current Status: + * - ❌ Not fully tested + * - ❌ May have breaking API changes + * - ❌ Not documented for public use + * - ❌ Currently being refactored and tested in enterprise + * + * When ready for use, this warning will be removed and proper documentation will be added. + * + * @internal - These exports are marked as internal and should not be used outside this package + */ + +import { skipToken, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { CreateProjectRequest, Project, UpdateProjectData } from "@/lib/types/projects"; +import { projectKeys } from "./keys"; +import * as projectService from "./service"; + +/** + * @internal - DO NOT USE: Still under development + */ +export function useProjects() { + return useQuery({ + queryKey: projectKeys.lists(), + queryFn: projectService.getProjects, + }); +} + +/** @internal - DO NOT USE: Still under development */ +export function useProjectArtifactsNew(projectId: string | null) { + return useQuery({ + queryKey: projectId ? projectKeys.artifacts(projectId) : ["projects", "artifacts", "empty"], + queryFn: projectId ? () => projectService.getProjectArtifacts(projectId) : skipToken, + }); +} + +/** @internal - DO NOT USE: Still under development */ +export function useCreateProject() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateProjectRequest) => projectService.createProject(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + }, + }); +} + +/** @internal - DO NOT USE: Still under development */ +export function useUpdateProject() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ projectId, data }: { projectId: string; data: UpdateProjectData }) => projectService.updateProject(projectId, data), + onSuccess: (updatedProject: Project) => { + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + queryClient.invalidateQueries({ queryKey: projectKeys.detail(updatedProject.id) }); + }, + }); +} + +/** @internal - DO NOT USE: Still under development */ +export function useDeleteProject() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (projectId: string) => projectService.deleteProject(projectId), + onSuccess: (_, projectId) => { + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + queryClient.removeQueries({ queryKey: projectKeys.detail(projectId) }); + }, + }); +} + +/** @internal - DO NOT USE: Still under development */ +export function useAddFilesToProject() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ projectId, files, fileMetadata }: { projectId: string; files: File[]; fileMetadata?: Record }) => projectService.addFilesToProject(projectId, files, fileMetadata), + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + queryClient.invalidateQueries({ queryKey: projectKeys.artifacts(projectId) }); + }, + }); +} + +/** @internal - DO NOT USE: Still under development */ +export function useRemoveFileFromProject() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ projectId, filename }: { projectId: string; filename: string }) => projectService.removeFileFromProject(projectId, filename), + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + queryClient.invalidateQueries({ queryKey: projectKeys.artifacts(projectId) }); + }, + }); +} + +/** @internal - DO NOT USE: Still under development */ +export function useUpdateFileMetadata() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ projectId, filename, description }: { projectId: string; filename: string; description: string }) => projectService.updateFileMetadata(projectId, filename, description), + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: projectKeys.artifacts(projectId) }); + }, + }); +} + +/** @internal - DO NOT USE: Still under development */ +export function useExportProject() { + return useMutation({ + mutationFn: (projectId: string) => projectService.exportProject(projectId), + }); +} + +/** @internal - DO NOT USE: Still under development */ +export function useImportProject() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ file, options }: { file: File; options: { preserveName: boolean; customName?: string } }) => projectService.importProject(file, options), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); + }, + }); +} diff --git a/client/webui/frontend/src/lib/api/projects/index.ts b/client/webui/frontend/src/lib/api/projects/index.ts new file mode 100644 index 000000000..007f69d09 --- /dev/null +++ b/client/webui/frontend/src/lib/api/projects/index.ts @@ -0,0 +1 @@ +export * from "./hooks"; diff --git a/client/webui/frontend/src/lib/api/projects/keys.ts b/client/webui/frontend/src/lib/api/projects/keys.ts new file mode 100644 index 000000000..cceaf43b8 --- /dev/null +++ b/client/webui/frontend/src/lib/api/projects/keys.ts @@ -0,0 +1,13 @@ +/** + * Query keys for React Query caching and invalidation + * Following the pattern: ['entity', ...filters/ids] + */ +export const projectKeys = { + all: ["projects"] as const, + lists: () => [...projectKeys.all, "list"] as const, + list: (filters?: Record) => [...projectKeys.lists(), { filters }] as const, + details: () => [...projectKeys.all, "detail"] as const, + detail: (id: string) => [...projectKeys.details(), id] as const, + artifacts: (id: string) => [...projectKeys.detail(id), "artifacts"] as const, + sessions: (id: string) => [...projectKeys.detail(id), "sessions"] as const, +}; diff --git a/client/webui/frontend/src/lib/api/projects/service.ts b/client/webui/frontend/src/lib/api/projects/service.ts new file mode 100644 index 000000000..79720873d --- /dev/null +++ b/client/webui/frontend/src/lib/api/projects/service.ts @@ -0,0 +1,81 @@ +import { api } from "@/lib/api"; +import type { ArtifactInfo, CreateProjectRequest, Project, UpdateProjectData } from "@/lib"; +import type { PaginatedSessionsResponse } from "@/lib/components/chat/SessionList"; + +export const getProjects = async () => { + const response = await api.webui.get<{ projects: Project[]; total: number }>("/api/v1/projects?include_artifact_count=true"); + return response; +}; + +export const createProject = async (data: CreateProjectRequest) => { + const formData = new FormData(); + formData.append("name", data.name); + + if (data.description) { + formData.append("description", data.description); + } + + const response = await api.webui.post("/api/v1/projects", formData); + return response; +}; + +export const addFilesToProject = async (projectId: string, files: File[], fileMetadata?: Record) => { + const formData = new FormData(); + + files.forEach(file => { + formData.append("files", file); + }); + + if (fileMetadata && Object.keys(fileMetadata).length > 0) { + formData.append("fileMetadata", JSON.stringify(fileMetadata)); + } + + const response = await api.webui.post(`/api/v1/projects/${projectId}/artifacts`, formData); + return response; +}; + +export const removeFileFromProject = async (projectId: string, filename: string) => { + const response = await api.webui.delete(`/api/v1/projects/${projectId}/artifacts/${encodeURIComponent(filename)}`); + return response; +}; + +export const updateFileMetadata = async (projectId: string, filename: string, description: string) => { + const formData = new FormData(); + formData.append("description", description); + + const response = await api.webui.patch(`/api/v1/projects/${projectId}/artifacts/${encodeURIComponent(filename)}`, formData); + return response; +}; + +export const updateProject = async (projectId: string, data: UpdateProjectData) => { + const response = await api.webui.put(`/api/v1/projects/${projectId}`, data); + return response; +}; + +export const deleteProject = async (projectId: string) => { + await api.webui.delete(`/api/v1/projects/${projectId}`); +}; + +export const getProjectArtifacts = async (projectId: string) => { + const response = await api.webui.get(`/api/v1/projects/${projectId}/artifacts`); + return response; +}; + +export const getProjectSessions = async (projectId: string) => { + const response = await api.webui.get(`/api/v1/sessions?project_id=${projectId}&pageNumber=1&pageSize=100`); + return response.data; +}; + +export const exportProject = async (projectId: string) => { + const response = await api.webui.get(`/api/v1/projects/${projectId}/export`, { fullResponse: true }); + return await response.blob(); +}; + +export const importProject = async (file: File, options: { preserveName: boolean; customName?: string }) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("options", JSON.stringify(options)); + + const result = await api.webui.post("/api/v1/projects/import", formData); + return result; +}; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/EdgeLayer.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/EdgeLayer.tsx new file mode 100644 index 000000000..98794c1cd --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/EdgeLayer.tsx @@ -0,0 +1,172 @@ +import React, { useState } from "react"; +import type { Edge } from "./utils/types"; + +interface EdgeLayerProps { + edges: Edge[]; + selectedEdgeId?: string | null; + onEdgeClick?: (edge: Edge) => void; +} + +const EdgeLayer: React.FC = ({ edges, selectedEdgeId, onEdgeClick }) => { + const [hoveredEdgeId, setHoveredEdgeId] = useState(null); + + // Calculate bezier curve path + const getBezierPath = (edge: Edge): string => { + const { sourceX, sourceY, targetX, targetY } = edge; + + // Calculate control points for bezier curve + const deltaY = targetY - sourceY; + const controlOffset = Math.min(Math.abs(deltaY) * 0.5, 100); + + const control1X = sourceX; + const control1Y = sourceY + controlOffset; + const control2X = targetX; + const control2Y = targetY - controlOffset; + + return `M ${sourceX} ${sourceY} C ${control1X} ${control1Y}, ${control2X} ${control2Y}, ${targetX} ${targetY}`; + }; + + // Get edge style + const getEdgeStyle = (edge: Edge, isHovered: boolean) => { + const isSelected = edge.id === selectedEdgeId; + + // Priority: Error > Selected > Hover > Default + if (edge.isError) { + return { + stroke: isHovered ? "#dc2626" : "#ef4444", + strokeWidth: isHovered ? 3 : 2, + }; + } + + if (isSelected) { + return { + stroke: "#3b82f6", + strokeWidth: 3, + }; + } + + if (isHovered) { + return { + stroke: "#6b7280", + strokeWidth: 3, + }; + } + + return { + stroke: "#9ca3af", + strokeWidth: 2, + }; + }; + + return ( + + + + + + + + + + + + + + {edges.map(edge => { + const isHovered = edge.id === hoveredEdgeId; + const isSelected = edge.id === selectedEdgeId; + const path = getBezierPath(edge); + const style = getEdgeStyle(edge, isHovered); + + // Determine marker + let markerEnd = "url(#arrowhead)"; + if (edge.isError) { + markerEnd = "url(#arrowhead-error)"; + } else if (isSelected) { + markerEnd = "url(#arrowhead-selected)"; + } + + return ( + + {/* Invisible wider path for easier clicking */} + setHoveredEdgeId(edge.id)} + onMouseLeave={() => setHoveredEdgeId(null)} + onClick={() => onEdgeClick?.(edge)} + /> + + {/* Visible edge path */} + + + {/* Label */} + {edge.label && isHovered && ( + + {edge.label} + + )} + + ); + })} + + ); +}; + +export default EdgeLayer; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/FlowChartPanel.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/FlowChartPanel.tsx new file mode 100644 index 000000000..035a497d7 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/FlowChartPanel.tsx @@ -0,0 +1,300 @@ +import React, { useCallback, useState, useRef, useEffect } from "react"; +import { Home } from "lucide-react"; +import type { VisualizerStep } from "@/lib/types"; +import { Dialog, DialogContent, DialogFooter, VisuallyHidden, DialogTitle, DialogDescription, Button, Tooltip, TooltipTrigger, TooltipContent } from "@/lib/components/ui"; +import { useTaskContext } from "@/lib/hooks"; +import { useAgentCards } from "@/lib/hooks"; +import WorkflowRenderer from "./WorkflowRenderer"; +import type { LayoutNode, Edge } from "./utils/types"; +import { findNodeDetails, type NodeDetails } from "./utils/nodeDetailsHelper"; +import NodeDetailsCard from "./NodeDetailsCard"; +import PanZoomCanvas, { type PanZoomCanvasRef } from "./PanZoomCanvas"; + +// Approximate width of the right side panel when visible +const RIGHT_PANEL_WIDTH = 400; + +interface FlowChartPanelProps { + processedSteps: VisualizerStep[]; + isRightPanelVisible?: boolean; + isSidePanelTransitioning?: boolean; +} + +const FlowChartPanel: React.FC = ({ + processedSteps, + isRightPanelVisible = false +}) => { + const { highlightedStepId, setHighlightedStepId } = useTaskContext(); + const { agentNameMap } = useAgentCards(); + + // Dialog state + const [selectedNodeDetails, setSelectedNodeDetails] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isDialogExpanded, setIsDialogExpanded] = useState(false); + + // Show detail toggle - controls whether to show nested agent internals + const [showDetail, setShowDetail] = useState(true); + + // Pan/zoom canvas ref + const canvasRef = useRef(null); + + // Ref to measure actual rendered content dimensions + const contentRef = useRef(null); + + // Track if user has manually interacted with pan/zoom + const hasUserInteracted = useRef(false); + const prevStepCount = useRef(processedSteps.length); + + // Track content dimensions (measured from actual DOM, adjusted for current scale) + // Using a ref so effects don't re-run when it changes + const contentWidthRef = useRef(800); + + // Use ResizeObserver to automatically detect content size changes + // This handles node expansions, collapses, and any other layout changes + useEffect(() => { + const element = contentRef.current; + if (!element) return; + + const measureContent = () => { + if (contentRef.current && canvasRef.current) { + const rect = contentRef.current.getBoundingClientRect(); + // getBoundingClientRect returns scaled dimensions, so divide by current scale + // to get the "natural" width at scale 1.0 + const currentScale = canvasRef.current.getTransform().scale; + const naturalWidth = rect.width / currentScale; + contentWidthRef.current = naturalWidth; + } + }; + + // Initial measurement + measureContent(); + + // Watch for size changes + const resizeObserver = new ResizeObserver(() => { + measureContent(); + }); + resizeObserver.observe(element); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + // Calculate side panel width for auto-fit calculations + const sidePanelWidth = isRightPanelVisible ? RIGHT_PANEL_WIDTH : 0; + + // Reset interaction flag when a new task starts (step count goes back to near zero) + useEffect(() => { + if (processedSteps.length <= 1) { + hasUserInteracted.current = false; + prevStepCount.current = 0; + } + }, [processedSteps.length]); + + // Auto-fit when new steps are added - only if user hasn't interacted + useEffect(() => { + const currentCount = processedSteps.length; + if (currentCount > prevStepCount.current && !hasUserInteracted.current) { + // New steps added and user hasn't interacted - fit to content with animation + setTimeout(() => { + canvasRef.current?.fitToContent(contentWidthRef.current, { animated: true }); + }, 150); // Longer delay to let content measurement update + } + prevStepCount.current = currentCount; + }, [processedSteps.length]); + + // Re-fit when showDetail changes - only if user hasn't manually adjusted the view + useEffect(() => { + if (!hasUserInteracted.current) { + setTimeout(() => { + canvasRef.current?.fitToContent(contentWidthRef.current, { animated: true, maxFitScale: 2.5 }); + }, 150); // Longer delay to let content measurement update + } + }, [showDetail]); + + // Re-fit when side panel visibility changes (if user hasn't interacted) + useEffect(() => { + if (!hasUserInteracted.current) { + setTimeout(() => { + canvasRef.current?.fitToContent(contentWidthRef.current, { animated: true }); + }, 150); + } + }, [isRightPanelVisible]); + + // Handler to mark user interaction + const handleUserInteraction = useCallback(() => { + hasUserInteracted.current = true; + }, []); + + // Handle node click + const handleNodeClick = useCallback( + (node: LayoutNode) => { + // Mark user interaction to stop auto-fit + hasUserInteracted.current = true; + + const stepId = node.data.visualizerStepId; + + // Find detailed information about this node + const nodeDetails = findNodeDetails(node, processedSteps); + + // Set highlighted step for synchronization with other views + if (stepId) { + setHighlightedStepId(stepId); + } + + if (isRightPanelVisible) { + // Right panel is open, just highlight + } else { + // Show dialog with node details + setSelectedNodeDetails(nodeDetails); + setIsDialogOpen(true); + } + }, + [processedSteps, isRightPanelVisible, setHighlightedStepId] + ); + + // Handle edge click + const handleEdgeClick = useCallback( + (edge: Edge) => { + const stepId = edge.visualizerStepId; + if (!stepId) return; + + // For edges, just highlight the step + setHighlightedStepId(stepId); + + // Note: Edges don't have request/result pairs like nodes do, + // so we don't show a popover for them + }, + [setHighlightedStepId] + ); + + // Handle dialog close + const handleDialogClose = useCallback(() => { + setIsDialogOpen(false); + setSelectedNodeDetails(null); + setIsDialogExpanded(false); + }, []); + + // Handle dialog width change from NodeDetailsCard (NP-3) + const handleDialogWidthChange = useCallback((isExpanded: boolean) => { + setIsDialogExpanded(isExpanded); + }, []); + + // Handle pane click (clear selection) + const handlePaneClick = useCallback( + (event: React.MouseEvent) => { + // Only clear if clicking on the wrapper itself, not on nodes + if (event.target === event.currentTarget) { + setHighlightedStepId(null); + } + }, + [setHighlightedStepId] + ); + + // Handle re-center button click - allow zooming in up to 2.5x + const handleRecenter = useCallback(() => { + canvasRef.current?.fitToContent(contentWidthRef.current, { animated: true, maxFitScale: 2.5 }); + hasUserInteracted.current = false; + }, []); + + return ( +
+ {/* Controls bar - Show Detail toggle and Re-center button */} +
+ {/* Re-center button (D-6) */} + + + + + Re-center diagram + + +
+ + + Show Detail + + + + + + {showDetail ? "Hide nested agent details" : "Show nested agent details"} + +
+ + +
+
+ +
+
+
+ + {/* Node Details Dialog */} + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + + Node Details + Details for the selected node + + {selectedNodeDetails && ( +
+ +
+ )} + + + +
+
+
+ ); +}; + +export default FlowChartPanel; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/NodeDetailsCard.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/NodeDetailsCard.tsx new file mode 100644 index 000000000..85fb98b82 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/NodeDetailsCard.tsx @@ -0,0 +1,1214 @@ +import React, { useState, useEffect } from "react"; +import { ArrowRight, Bot, CheckCircle, Eye, FileText, GitBranch, Loader2, RefreshCw, Terminal, User, Workflow, Wrench, X, Zap } from "lucide-react"; +import type { NodeDetails } from "./utils/nodeDetailsHelper"; +import { JSONViewer, MarkdownHTMLConverter } from "@/lib/components"; +import type { VisualizerStep, ToolDecision } from "@/lib/types"; +import { useChatContext } from "@/lib/hooks"; +import { parseArtifactUri } from "@/lib/utils/download"; +import { api } from "@/lib/api"; + +const MAX_ARTIFACT_DISPLAY_LENGTH = 5000; + +interface ArtifactContentViewerProps { + uri?: string; + name: string; + version?: number; + mimeType?: string; +} + +/** + * Component to fetch and display artifact content inline + */ +const ArtifactContentViewer: React.FC = ({ uri, name, version, mimeType }) => { + const { sessionId } = useChatContext(); + const [content, setContent] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + const fetchContent = async () => { + if (!uri && !name) return; + + setIsLoading(true); + setError(null); + + try { + let filename = name; + let artifactVersion = version?.toString() || "latest"; + + // Try to parse URI if available + if (uri) { + const parsed = parseArtifactUri(uri); + if (parsed) { + filename = parsed.filename; + if (parsed.version) { + artifactVersion = parsed.version; + } + } + } + + // Construct API endpoint + const endpoint = `/api/v1/artifacts/${encodeURIComponent(sessionId || "null")}/${encodeURIComponent(filename)}/versions/${artifactVersion}`; + + const response = await api.webui.get(endpoint, { fullResponse: true, credentials: "include" }); + if (!response.ok) { + throw new Error(`Failed to fetch artifact: ${response.statusText}`); + } + + const blob = await response.blob(); + const text = await blob.text(); + + // Truncate if too long + if (text.length > MAX_ARTIFACT_DISPLAY_LENGTH) { + setContent(text.substring(0, MAX_ARTIFACT_DISPLAY_LENGTH)); + setIsTruncated(true); + } else { + setContent(text); + setIsTruncated(false); + } + } catch (err) { + console.error("Error fetching artifact:", err); + setError(err instanceof Error ? err.message : "Failed to load artifact"); + } finally { + setIsLoading(false); + } + }; + + fetchContent(); + }, [uri, name, version, sessionId]); + + const renderContent = () => { + if (!content) return null; + + const effectiveMimeType = mimeType || (name.endsWith(".json") ? "application/json" : + name.endsWith(".yaml") || name.endsWith(".yml") ? "text/yaml" : + name.endsWith(".csv") ? "text/csv" : "text/plain"); + + // Try to parse and format JSON + if (effectiveMimeType === "application/json" || name.endsWith(".json")) { + try { + const parsed = JSON.parse(content); + return ( +
+ +
+ ); + } catch { + // Fall through to plain text + } + } + + // For YAML, CSV, and other text formats, show as preformatted text + return ( +
+                {content}
+            
+ ); + }; + + if (isLoading) { + return ( +
+ + Loading artifact content... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!content) { + return ( +
+ No content available +
+ ); + } + + return ( +
+ {renderContent()} + {isTruncated && ( +
+ Content truncated (showing first {MAX_ARTIFACT_DISPLAY_LENGTH.toLocaleString()} characters) +
+ )} +
+ ); +}; + +interface NodeDetailsCardProps { + nodeDetails: NodeDetails; + onClose?: () => void; + onWidthChange?: (isExpanded: boolean) => void; +} + +/** + * Component to display detailed request and result information for a clicked node + */ +const NodeDetailsCard: React.FC = ({ nodeDetails, onClose, onWidthChange }) => { + const { artifacts, setPreviewArtifact: setSidePanelPreviewArtifact, setActiveSidePanelTab, setIsSidePanelCollapsed, navigateArtifactVersion } = useChatContext(); + + // Local state for inline artifact preview (NP-3) + const [inlinePreviewArtifact, setInlinePreviewArtifact] = useState<{ name: string; version?: number; mimeType?: string } | null>(null); + + // Notify parent when expansion state changes + useEffect(() => { + onWidthChange?.(inlinePreviewArtifact !== null); + }, [inlinePreviewArtifact, onWidthChange]); + + const getNodeIcon = () => { + switch (nodeDetails.nodeType) { + case 'user': + return ; + case 'agent': + return ; + case 'llm': + return ; + case 'tool': + return ; + case 'switch': + return ; + case 'loop': + return ; + case 'group': + return ; + default: + return ; + } + }; + + const renderStepContent = (step: VisualizerStep | undefined, isRequest: boolean) => { + if (!step) { + return ( +
+ {isRequest ? "No request data available" : "No result data available"} +
+ ); + } + + // Format timestamp with milliseconds + const date = new Date(step.timestamp); + const timeString = date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, // Use 24-hour format + }); + const milliseconds = String(date.getMilliseconds()).padStart(3, "0"); + const formattedTimestamp = `${timeString}.${milliseconds}`; + + return ( +
+ {/* Timestamp */} +
+ {formattedTimestamp} +
+ + {/* Step-specific content */} + {renderStepTypeContent(step)} +
+ ); + }; + + const renderStepTypeContent = (step: VisualizerStep) => { + switch (step.type) { + case 'USER_REQUEST': + return renderUserRequest(step); + case 'WORKFLOW_AGENT_REQUEST': + return renderWorkflowAgentRequest(step); + case 'AGENT_RESPONSE_TEXT': + return renderAgentResponse(step); + case 'AGENT_LLM_CALL': + return renderLLMCall(step); + case 'AGENT_LLM_RESPONSE_TO_AGENT': + return renderLLMResponse(step); + case 'AGENT_LLM_RESPONSE_TOOL_DECISION': + return renderLLMToolDecision(step); + case 'AGENT_TOOL_INVOCATION_START': + return renderToolInvocation(step); + case 'AGENT_TOOL_EXECUTION_RESULT': + return renderToolResult(step); + case 'WORKFLOW_NODE_EXECUTION_START': + // For agent nodes in workflows, show as agent invocation + if (step.data.workflowNodeExecutionStart?.nodeType === 'agent') { + return renderWorkflowAgentInvocation(step); + } + return renderWorkflowNodeStart(step); + case 'WORKFLOW_NODE_EXECUTION_RESULT': + return renderWorkflowNodeResult(step); + case 'WORKFLOW_EXECUTION_START': + return renderWorkflowStart(step); + case 'WORKFLOW_EXECUTION_RESULT': + return renderWorkflowResult(step); + default: + return ( +
+ {step.title} +
+ ); + } + }; + + const renderUserRequest = (step: VisualizerStep) => ( +
+

User Input

+ {step.data.text && ( +
+ {step.data.text} +
+ )} +
+ ); + + const renderWorkflowAgentRequest = (step: VisualizerStep) => { + const data = step.data.workflowAgentRequest; + if (!data) return null; + + return ( +
+

+ Workflow Agent Request +

+
+ {data.nodeId && ( +
+ Node Id:{' '} + {data.nodeId} +
+ )} + + {/* Instruction from workflow node */} + {data.instruction && ( +
+
Instruction:
+
+ {data.instruction} +
+
+ )} + + {/* Input as artifact reference */} + {data.inputArtifactRef && ( +
+
+ Input: + {data.inputArtifactRef.name} + {data.inputArtifactRef.version !== undefined && ( + v{data.inputArtifactRef.version} + )} +
+
+ +
+
+ )} + + {/* Input as text (for simple text schemas) */} + {data.inputText && !data.inputArtifactRef && ( +
+
Input:
+
+ {data.inputText} +
+
+ )} + + {/* Input Schema */} + {data.inputSchema && ( +
+
Input Schema:
+
+ +
+
+ )} + + {/* Output Schema */} + {data.outputSchema && ( +
+
Output Schema:
+
+ +
+
+ )} + + {/* No input data available */} + {!data.inputText && !data.inputArtifactRef && !data.instruction && ( +
+ No input data available +
+ )} +
+
+ ); + }; + + const renderAgentResponse = (step: VisualizerStep) => ( +
+

Agent Response

+ {step.data.text && ( +
+ {step.data.text} +
+ )} +
+ ); + + const renderLLMCall = (step: VisualizerStep) => { + const data = step.data.llmCall; + if (!data) return null; + + return ( +
+

LLM Request

+
+
+ Model: {data.modelName} +
+
+
Prompt:
+
+                            {data.promptPreview}
+                        
+
+
+
+ ); + }; + + const renderLLMResponse = (step: VisualizerStep) => { + const data = step.data.llmResponseToAgent; + if (!data) return null; + + return ( +
+

LLM Response

+
+ {data.modelName && ( +
+ Model: {data.modelName} +
+ )} +
+
+                            {data.response || data.responsePreview}
+                        
+
+ {data.isFinalResponse !== undefined && ( +
+ Final Response: {data.isFinalResponse ? "Yes" : "No"} +
+ )} +
+
+ ); + }; + + const renderLLMToolDecision = (step: VisualizerStep) => { + const data = step.data.toolDecision; + if (!data) return null; + + return ( +
+

+ LLM Tool Decision{data.isParallel ? " (Parallel)" : ""} +

+
+ {data.decisions && data.decisions.length > 0 && ( +
+
Tools to invoke:
+
+ {data.decisions.map((decision: ToolDecision, index: number) => ( +
+
+ {decision.toolName} + {decision.isPeerDelegation && ( + + {decision.toolName.startsWith('workflow_') ? 'Workflow' : 'Peer Agent'} + + )} +
+ {decision.toolArguments && Object.keys(decision.toolArguments).length > 0 && ( +
+ {renderFormattedArguments(decision.toolArguments)} +
+ )} +
+ ))} +
+
+ )} +
+
+ ); + }; + + const renderToolInvocation = (step: VisualizerStep) => { + const data = step.data.toolInvocationStart; + if (!data) return null; + + return ( +
+

+ {data.isPeerInvocation ? "Peer Agent Call" : "Tool Invocation"} +

+
+
+ Tool: {data.toolName} +
+
+
Arguments:
+ {renderFormattedArguments(data.toolArguments)} +
+
+
+ ); + }; + + const renderFormattedArguments = (args: Record) => { + const entries = Object.entries(args); + + if (entries.length === 0) { + return ( +
+ No arguments +
+ ); + } + + return ( +
+ {entries.map(([key, value]) => ( +
+
+ {key} +
+
+ {renderArgumentValue(value)} +
+
+ ))} +
+ ); + }; + + const renderArgumentValue = (value: any): React.ReactNode => { + // Handle null/undefined + if (value === null) { + return null; + } + if (value === undefined) { + return undefined; + } + + // Handle primitives + if (typeof value === 'string') { + return {value}; + } + if (typeof value === 'number') { + return {value}; + } + if (typeof value === 'boolean') { + return {value.toString()}; + } + + // Handle arrays + if (Array.isArray(value)) { + if (value.length === 0) { + return []; + } + // For simple arrays of primitives, show inline + if (value.every(item => typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean')) { + return ( +
+ {value.map((item, idx) => ( +
+ {renderArgumentValue(item)} +
+ ))} +
+ ); + } + // For complex arrays, use JSONViewer + return ( +
+ +
+ ); + } + + // Handle objects + if (typeof value === 'object') { + const entries = Object.entries(value); + + // For small objects with simple values, render inline + if (entries.length <= 5 && entries.every(([_, v]) => + typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null + )) { + return ( +
+ {entries.map(([k, v]) => ( +
+ {k}: + {renderArgumentValue(v)} +
+ ))} +
+ ); + } + + // For complex objects, use JSONViewer + return ( +
+ +
+ ); + } + + // Fallback + return {String(value)}; + }; + + const renderToolResult = (step: VisualizerStep) => { + const data = step.data.toolResult; + if (!data) return null; + + return ( +
+

+ {data.isPeerResponse ? "Peer Agent Result" : "Tool Result"} +

+
+
+ Tool: {data.toolName} +
+
+
Result:
+ {typeof data.resultData === "object" && data.resultData !== null ? ( + renderFormattedArguments(data.resultData) + ) : ( +
+
+ {renderArgumentValue(data.resultData)} +
+
+ )} +
+
+
+ ); + }; + + const renderWorkflowAgentInvocation = (step: VisualizerStep) => { + const data = step.data.workflowNodeExecutionStart; + if (!data) return null; + + return ( +
+

+ Workflow Agent Invocation +

+
+
+ Agent: {data.agentName || data.nodeId} +
+
+ Workflow Node: {data.nodeId} +
+ {(data.iterationIndex !== undefined && data.iterationIndex !== null && typeof data.iterationIndex === 'number') && ( +
+ Iteration #{data.iterationIndex} +
+ )} + {data.inputArtifactRef && ( +
+
Input:
+
+
+
+ Artifact Reference +
+
+
+ name: + {data.inputArtifactRef.name} +
+ {data.inputArtifactRef.version !== undefined && ( +
+ version: + {data.inputArtifactRef.version} +
+ )} +
+
+
+
+ )} +
+ This agent was invoked by the workflow with the input specified above. +
+
+
+ ); + }; + + const renderWorkflowNodeStart = (step: VisualizerStep) => { + const data = step.data.workflowNodeExecutionStart; + if (!data) return null; + + // Switch node specific rendering + if (data.nodeType === 'switch') { + return ( +
+

+ Switch Node +

+
+
+ Node ID: {data.nodeId} +
+ + {/* Cases */} + {data.cases && data.cases.length > 0 && ( +
+
Cases:
+
+ {data.cases.map((caseItem, index) => ( +
+
+ + Case {index + 1} + + + + {caseItem.node} + +
+ + {caseItem.condition} + +
+ ))} +
+
+ )} + + {/* Default branch */} + {data.defaultBranch && ( +
+
+ + Default + + + + {data.defaultBranch} + +
+
+ )} +
+
+ ); + } + + // Loop node specific rendering + if (data.nodeType === 'loop') { + return ( +
+

+ Loop Node +

+
+
+ Node ID: {data.nodeId} +
+ {data.condition && ( +
+
Condition:
+ + {data.condition} + +
+ )} + {data.maxIterations !== undefined && ( +
+ Max Iterations: {data.maxIterations} +
+ )} + {data.loopDelay && ( +
+ Delay: {data.loopDelay} +
+ )} +
+
+ ); + } + + // Default rendering for other node types + return ( +
+

+ Workflow Node Start +

+
+
+ Node ID: {data.nodeId} +
+
+ Type: {data.nodeType} +
+ {data.agentName && ( +
+ Agent: {data.agentName} +
+ )} + {data.condition && ( +
+
Condition:
+ + {data.condition} + +
+ )} + {(data.iterationIndex !== undefined && data.iterationIndex !== null && typeof data.iterationIndex === 'number') && ( +
+ Iteration #{data.iterationIndex} +
+ )} +
+
+ ); + }; + + const renderWorkflowNodeResult = (step: VisualizerStep) => { + const data = step.data.workflowNodeExecutionResult; + if (!data) return null; + + // Extract switch-specific data from metadata + const selectedBranch = data.metadata?.selected_branch; + const selectedCaseIndex = data.metadata?.selected_case_index; + const isSwitch = selectedBranch !== undefined || selectedCaseIndex !== undefined; + + return ( +
+

+ {isSwitch ? "Switch Result" : "Workflow Node Result"} +

+
+
+ Status:{" "} + + {data.status} + +
+ + {/* Switch node result - selected branch */} + {selectedBranch !== undefined && ( +
+
+ + + Selected Branch: + + + {selectedBranch} + +
+ {selectedCaseIndex !== undefined && selectedCaseIndex !== null && ( +
+ Matched Case #{selectedCaseIndex + 1} +
+ )} + {selectedCaseIndex === null && ( +
+ (Default branch - no case matched) +
+ )} +
+ )} + + {data.conditionResult !== undefined && ( +
+ Condition Result:{" "} + + {data.conditionResult ? "True" : "False"} + +
+ )} + {data.metadata?.condition && ( +
+
Condition:
+ + {data.metadata.condition} + +
+ )} + {data.errorMessage && ( +
+ Error: {data.errorMessage} +
+ )} +
+
+ ); + }; + + const renderWorkflowStart = (step: VisualizerStep) => { + const data = step.data.workflowExecutionStart; + if (!data) return null; + + return ( +
+

+ Workflow Start +

+
+
+ Workflow: {data.workflowName} +
+ {data.workflowInput && ( +
+
Input:
+ {renderFormattedArguments(data.workflowInput)} +
+ )} +
+
+ ); + }; + + const renderWorkflowResult = (step: VisualizerStep) => { + const data = step.data.workflowExecutionResult; + if (!data) return null; + + return ( +
+

+ Workflow Result +

+
+
+ Status:{" "} + + {data.status} + +
+ {data.workflowOutput && ( +
+
Output:
+ {renderFormattedArguments(data.workflowOutput)} +
+ )} + {data.errorMessage && ( +
+ Error: {data.errorMessage} +
+ )} +
+
+ ); + }; + + const hasRequestAndResult = nodeDetails.requestStep && nodeDetails.resultStep; + const hasCreatedArtifacts = nodeDetails.createdArtifacts && nodeDetails.createdArtifacts.length > 0; + + // Helper to render output artifact if available + const renderOutputArtifact = () => { + const outputArtifactRef = nodeDetails.outputArtifactStep?.data?.workflowNodeExecutionResult?.outputArtifactRef; + if (!outputArtifactRef) return null; + + return ( +
+
+ Output Artifact: + {outputArtifactRef.name} + {outputArtifactRef.version !== undefined && ( + v{outputArtifactRef.version} + )} +
+
+ +
+
+ ); + }; + + // Helper to render created artifacts for tool nodes + // When asColumn is true, renders without the top border (for 3-column layout) + const renderCreatedArtifacts = (asColumn: boolean = false) => { + if (!nodeDetails.createdArtifacts || nodeDetails.createdArtifacts.length === 0) return null; + + const handleArtifactClick = (filename: string, version?: number) => { + // Find the artifact by filename + const artifact = artifacts.find(a => a.filename === filename); + + if (artifact) { + // Switch to Files tab + setActiveSidePanelTab("files"); + + // Expand side panel if collapsed + setIsSidePanelCollapsed(false); + + // Set preview artifact to open the file + setSidePanelPreviewArtifact(artifact); + + // If a specific version is indicated, navigate to it + if (version !== undefined && version !== artifact.version) { + // Wait a bit for the file to load, then navigate to the specific version + setTimeout(() => { + navigateArtifactVersion(filename, version); + }, 100); + } + + // Close the popover + onClose?.(); + } + }; + + return ( +
+
+
+ +

+ {asColumn ? "CREATED ARTIFACTS" : `Created Artifacts (${nodeDetails.createdArtifacts.length})`} +

+
+
+ {nodeDetails.createdArtifacts.map((artifact, index) => ( +
+
+ +
+ {/* Inline preview button (NP-3) */} + + {artifact.version !== undefined && ( + + v{artifact.version} + + )} +
+
+ {artifact.description && ( +

+ {artifact.description} +

+ )} + {artifact.mimeType && ( +
+ Type: {artifact.mimeType} +
+ )} +
+ +
+
+ ))} +
+
+ ); + }; + + // Render the main node details content + const renderMainContent = () => ( +
+ {/* Header */} +
+ {getNodeIcon()} +
+

+ {nodeDetails.label} +

+ {nodeDetails.description ? ( +

+ {nodeDetails.description} +

+ ) : ( +

+ {nodeDetails.nodeType} Node +

+ )} +
+
+ + {/* Content */} +
+ {hasRequestAndResult ? ( + /* Split view for request and result (and optionally created artifacts) */ +
+ {/* Request Column */} +
+
+
+

+ REQUEST +

+
+ {renderStepContent(nodeDetails.requestStep, true)} +
+ + {/* Result Column */} +
+
+
+

+ RESULT +

+
+ {renderStepContent(nodeDetails.resultStep, false)} + {renderOutputArtifact()} +
+ + {/* Created Artifacts Column (when present) */} + {hasCreatedArtifacts && ( +
+ {renderCreatedArtifacts(true)} +
+ )} +
+ ) : ( + /* Single view when only request or result is available */ +
+ {nodeDetails.requestStep && ( +
+
+
+

+ REQUEST +

+
+ {renderStepContent(nodeDetails.requestStep, true)} +
+ )} + {nodeDetails.resultStep && ( +
+
+
+

+ RESULT +

+
+ {renderStepContent(nodeDetails.resultStep, false)} + {renderOutputArtifact()} + {renderCreatedArtifacts()} +
+ )} + {!nodeDetails.requestStep && !nodeDetails.resultStep && ( +
+ No detailed information available for this node +
+ )} +
+ )} +
+
+ ); + + // Render the inline artifact preview panel (NP-3) + const renderArtifactPreviewPanel = () => { + if (!inlinePreviewArtifact) return null; + + return ( +
+ {/* Preview Header */} +
+
+ +
+

+ {inlinePreviewArtifact.name} +

+ {inlinePreviewArtifact.version !== undefined && ( +

+ Version {inlinePreviewArtifact.version} +

+ )} +
+
+ +
+ + {/* Preview Content */} +
+ +
+
+ ); + }; + + return ( +
+ {/* Main content */} +
+ {renderMainContent()} +
+ + {/* Artifact preview panel (NP-3) */} + {renderArtifactPreviewPanel()} +
+ ); +}; + +export default NodeDetailsCard; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/PanZoomCanvas.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/PanZoomCanvas.tsx new file mode 100644 index 000000000..18dd96bf7 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/PanZoomCanvas.tsx @@ -0,0 +1,489 @@ +import React, { useRef, useState, useCallback, useEffect } from "react"; + +interface PanZoomCanvasProps { + children: React.ReactNode; + initialScale?: number; + minScale?: number; + maxScale?: number; + onTransformChange?: (transform: { scale: number; x: number; y: number }) => void; + onUserInteraction?: () => void; + /** Width of any side panel that reduces available viewport width */ + sidePanelWidth?: number; +} + +export interface PanZoomCanvasRef { + resetTransform: () => void; + getTransform: () => { scale: number; x: number; y: number }; + /** Fit content to viewport, showing full width and top-aligned */ + fitToContent: (contentWidth: number, options?: { animated?: boolean; maxFitScale?: number }) => void; + /** Zoom in by 10% (rounded to nearest 10%), centered on viewport */ + zoomIn: (options?: { animated?: boolean }) => void; + /** Zoom out by 10% (rounded to nearest 10%), centered on viewport */ + zoomOut: (options?: { animated?: boolean }) => void; + /** Zoom to a specific scale, centered on viewport or specified point */ + zoomTo: (scale: number, options?: { animated?: boolean; centerX?: number; centerY?: number }) => void; + /** Pan to center a point (in content coordinates) in the viewport */ + panToPoint: (contentX: number, contentY: number, options?: { animated?: boolean }) => void; +} + +interface PointerState { + x: number; + y: number; +} + +interface GestureState { + centerX: number; + centerY: number; + distance: number; +} + +const PanZoomCanvas = React.forwardRef( + ( + { + children, + initialScale = 1, + minScale = 0.1, + maxScale = 4, + onTransformChange, + onUserInteraction, + sidePanelWidth = 0, + }, + ref + ) => { + const containerRef = useRef(null); + const [transform, setTransform] = useState({ + scale: initialScale, + x: 0, + y: 0, + }); + const [isAnimating, setIsAnimating] = useState(false); + + // Track active pointers for multi-touch + const pointersRef = useRef>(new Map()); + const lastGestureRef = useRef(null); + const isDraggingRef = useRef(false); + const lastDragPosRef = useRef<{ x: number; y: number } | null>(null); + + // Clamp scale within bounds (defined early for use in ref methods) + const clampScale = useCallback( + (scale: number) => Math.min(Math.max(scale, minScale), maxScale), + [minScale, maxScale] + ); + + // Expose methods via ref + React.useImperativeHandle(ref, () => ({ + resetTransform: () => { + setTransform({ scale: initialScale, x: 0, y: 0 }); + }, + getTransform: () => transform, + fitToContent: (contentWidth: number, options?: { animated?: boolean; maxFitScale?: number }) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + // Account for side panel width + const availableWidth = rect.width - sidePanelWidth; + + // Padding around the content + const padding = 80; // 40px on each side + const topPadding = 60; // Extra space at top for controls + + // Calculate scale to fit width + // Default max is 1.0 (don't zoom in), but can be overridden + const fitMaxScale = options?.maxFitScale ?? 1.0; + const scaleToFitWidth = (availableWidth - padding) / contentWidth; + const newScale = Math.min(Math.max(scaleToFitWidth, minScale), fitMaxScale); + + // Center horizontally, align to top + const scaledContentWidth = contentWidth * newScale; + const newX = (availableWidth - scaledContentWidth) / 2; + const newY = topPadding; + + if (options?.animated) { + setIsAnimating(true); + // Disable animation after transition completes + setTimeout(() => setIsAnimating(false), 300); + } + + setTransform({ scale: newScale, x: newX, y: newY }); + }, + zoomIn: (options?: { animated?: boolean }) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + // Zoom toward center of viewport (accounting for side panel) + const centerX = (rect.width - sidePanelWidth) / 2; + const centerY = rect.height / 2; + + setTransform(prev => { + // Round to nearest 10% and add 10% + const currentPercent = Math.round(prev.scale * 100); + const roundedPercent = Math.round(currentPercent / 10) * 10; + const targetPercent = Math.min(roundedPercent + 10, maxScale * 100); + const newScale = clampScale(targetPercent / 100); + const scaleRatio = newScale / prev.scale; + + // Zoom toward center + const newX = centerX - (centerX - prev.x) * scaleRatio; + const newY = centerY - (centerY - prev.y) * scaleRatio; + + return { scale: newScale, x: newX, y: newY }; + }); + + if (options?.animated) { + setIsAnimating(true); + setTimeout(() => setIsAnimating(false), 300); + } + }, + zoomOut: (options?: { animated?: boolean }) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + // Zoom toward center of viewport (accounting for side panel) + const centerX = (rect.width - sidePanelWidth) / 2; + const centerY = rect.height / 2; + + setTransform(prev => { + // Round to nearest 10% and subtract 10% + const currentPercent = Math.round(prev.scale * 100); + const roundedPercent = Math.round(currentPercent / 10) * 10; + const targetPercent = Math.max(roundedPercent - 10, minScale * 100); + const newScale = clampScale(targetPercent / 100); + const scaleRatio = newScale / prev.scale; + + // Zoom toward center + const newX = centerX - (centerX - prev.x) * scaleRatio; + const newY = centerY - (centerY - prev.y) * scaleRatio; + + return { scale: newScale, x: newX, y: newY }; + }); + + if (options?.animated) { + setIsAnimating(true); + setTimeout(() => setIsAnimating(false), 300); + } + }, + zoomTo: (targetScale: number, options?: { animated?: boolean; centerX?: number; centerY?: number }) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + // Use provided center or viewport center + const centerX = options?.centerX ?? (rect.width - sidePanelWidth) / 2; + const centerY = options?.centerY ?? rect.height / 2; + + const newScale = clampScale(targetScale); + + setTransform(prev => { + const scaleRatio = newScale / prev.scale; + const newX = centerX - (centerX - prev.x) * scaleRatio; + const newY = centerY - (centerY - prev.y) * scaleRatio; + return { scale: newScale, x: newX, y: newY }; + }); + + if (options?.animated) { + setIsAnimating(true); + setTimeout(() => setIsAnimating(false), 300); + } + }, + panToPoint: (contentX: number, contentY: number, options?: { animated?: boolean }) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + // Calculate viewport center (accounting for side panel) + const viewportCenterX = (rect.width - sidePanelWidth) / 2; + const viewportCenterY = rect.height / 2; + + setTransform(prev => { + // Convert content coordinates to screen coordinates at current scale + // Then calculate the offset needed to center that point + const newX = viewportCenterX - contentX * prev.scale; + const newY = viewportCenterY - contentY * prev.scale; + return { ...prev, x: newX, y: newY }; + }); + + if (options?.animated) { + setIsAnimating(true); + setTimeout(() => setIsAnimating(false), 300); + } + }, + })); + + // Notify parent of transform changes + useEffect(() => { + onTransformChange?.(transform); + }, [transform, onTransformChange]); + + // Calculate gesture state from two pointers + const calculateGestureState = useCallback((pointers: Map): GestureState | null => { + const points = Array.from(pointers.values()); + if (points.length < 2) return null; + + const [p1, p2] = points; + const centerX = (p1.x + p2.x) / 2; + const centerY = (p1.y + p2.y) / 2; + const distance = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + + return { centerX, centerY, distance }; + }, []); + + // Handle pointer down + const handlePointerDown = useCallback((e: React.PointerEvent) => { + // Capture the pointer for tracking + (e.target as HTMLElement).setPointerCapture(e.pointerId); + + pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + if (pointersRef.current.size === 1) { + // Single pointer - start drag + isDraggingRef.current = true; + lastDragPosRef.current = { x: e.clientX, y: e.clientY }; + } else if (pointersRef.current.size === 2) { + // Two pointers - start pinch gesture + isDraggingRef.current = false; + lastDragPosRef.current = null; + lastGestureRef.current = calculateGestureState(pointersRef.current); + } + }, [calculateGestureState]); + + // Handle pointer move + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!pointersRef.current.has(e.pointerId)) return; + + // Update pointer position + pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + if (pointersRef.current.size >= 2) { + // Multi-touch: calculate pan AND zoom simultaneously + const currentGesture = calculateGestureState(pointersRef.current); + const lastGesture = lastGestureRef.current; + + if (currentGesture && lastGesture) { + // Pan: movement of the center point (average finger displacement) + const panDeltaX = currentGesture.centerX - lastGesture.centerX; + const panDeltaY = currentGesture.centerY - lastGesture.centerY; + + // Zoom: change in distance between fingers + const zoomFactor = currentGesture.distance / lastGesture.distance; + + // Pinch center relative to container + const cursorX = currentGesture.centerX - rect.left; + const cursorY = currentGesture.centerY - rect.top; + + setTransform((prev) => { + const newScale = clampScale(prev.scale * zoomFactor); + const scaleRatio = newScale / prev.scale; + + // Apply zoom toward pinch center + let newX = cursorX - (cursorX - prev.x) * scaleRatio; + let newY = cursorY - (cursorY - prev.y) * scaleRatio; + + // Apply pan from finger movement (simultaneously!) + newX += panDeltaX; + newY += panDeltaY; + + return { scale: newScale, x: newX, y: newY }; + }); + + onUserInteraction?.(); + } + + lastGestureRef.current = currentGesture; + } else if (isDraggingRef.current && lastDragPosRef.current) { + // Single pointer drag - pan only + const dx = e.clientX - lastDragPosRef.current.x; + const dy = e.clientY - lastDragPosRef.current.y; + + setTransform((prev) => ({ + ...prev, + x: prev.x + dx, + y: prev.y + dy, + })); + + lastDragPosRef.current = { x: e.clientX, y: e.clientY }; + onUserInteraction?.(); + } + }, [calculateGestureState, clampScale, onUserInteraction]); + + // Handle pointer up/cancel + const handlePointerUp = useCallback((e: React.PointerEvent) => { + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + pointersRef.current.delete(e.pointerId); + + if (pointersRef.current.size < 2) { + lastGestureRef.current = null; + } + + if (pointersRef.current.size === 1) { + // Went from 2 to 1 pointer - switch to drag mode + const remaining = Array.from(pointersRef.current.values())[0]; + isDraggingRef.current = true; + lastDragPosRef.current = { x: remaining.x, y: remaining.y }; + } else if (pointersRef.current.size === 0) { + isDraggingRef.current = false; + lastDragPosRef.current = null; + } + }, []); + + // Handle wheel events (mouse wheel zoom + trackpad gestures) + // Must be added manually with { passive: false } to allow preventDefault() + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + + const rect = container.getBoundingClientRect(); + + const dx = e.deltaX; + const dy = e.deltaY; + const ctrlKey = e.ctrlKey || e.metaKey; + const shiftKey = e.shiftKey; + + // Check if this looks like a trackpad 2-finger swipe (has horizontal component) + const isTrackpadSwipe = Math.abs(dx) > 1 && !ctrlKey; + + if (shiftKey) { + // Shift+scroll -> pan horizontally + setTransform((prev) => ({ + ...prev, + x: prev.x - dy, + })); + onUserInteraction?.(); + } else if (ctrlKey) { + // Trackpad pinch -> zoom + pan + const zoomFactor = 1 - dy * 0.01; + const cursorX = e.clientX - rect.left; + const cursorY = e.clientY - rect.top; + + setTransform((prev) => { + const newScale = clampScale(prev.scale * zoomFactor); + const scaleRatio = newScale / prev.scale; + + let newX = cursorX - (cursorX - prev.x) * scaleRatio; + let newY = cursorY - (cursorY - prev.y) * scaleRatio; + newX -= dx; + + return { scale: newScale, x: newX, y: newY }; + }); + onUserInteraction?.(); + } else if (isTrackpadSwipe) { + // Trackpad 2-finger swipe -> pan + setTransform((prev) => ({ + ...prev, + x: prev.x - dx, + y: prev.y - dy, + })); + onUserInteraction?.(); + } else { + // Mouse wheel -> zoom toward cursor + const zoomFactor = 1 - dy * 0.005; + const cursorX = e.clientX - rect.left; + const cursorY = e.clientY - rect.top; + + setTransform((prev) => { + const newScale = clampScale(prev.scale * zoomFactor); + const scaleRatio = newScale / prev.scale; + + const newX = cursorX - (cursorX - prev.x) * scaleRatio; + const newY = cursorY - (cursorY - prev.y) * scaleRatio; + + return { scale: newScale, x: newX, y: newY }; + }); + onUserInteraction?.(); + } + }; + + // Add with passive: false to allow preventDefault() + container.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + container.removeEventListener("wheel", handleWheel); + }; + }, [clampScale, onUserInteraction]); + + // Handle double-click to zoom in at click location + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + // Prevent text selection on double-click + e.preventDefault(); + + // Only handle if clicking on the container background (not on interactive nodes) + // Check if the click target is a button, link, or has data-no-zoom attribute + const target = e.target as HTMLElement; + if ( + target.closest("button") || + target.closest("a") || + target.closest("[data-no-zoom]") || + target.closest("[role='button']") + ) { + return; + } + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const cursorX = e.clientX - rect.left; + const cursorY = e.clientY - rect.top; + + // Zoom in by 10% toward click location + setTransform(prev => { + const currentPercent = Math.round(prev.scale * 100); + const roundedPercent = Math.round(currentPercent / 10) * 10; + const targetPercent = Math.min(roundedPercent + 10, maxScale * 100); + const newScale = clampScale(targetPercent / 100); + const scaleRatio = newScale / prev.scale; + + const newX = cursorX - (cursorX - prev.x) * scaleRatio; + const newY = cursorY - (cursorY - prev.y) * scaleRatio; + + return { scale: newScale, x: newX, y: newY }; + }); + + setIsAnimating(true); + setTimeout(() => setIsAnimating(false), 300); + onUserInteraction?.(); + }, [clampScale, maxScale, onUserInteraction]); + + return ( +
+
+ {children} +
+
+ ); + } +); + +PanZoomCanvas.displayName = "PanZoomCanvas"; + +export default PanZoomCanvas; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/WorkflowRenderer.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/WorkflowRenderer.tsx new file mode 100644 index 000000000..87ebdaff3 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/WorkflowRenderer.tsx @@ -0,0 +1,358 @@ +import React, { useMemo, useState } from "react"; +import type { VisualizerStep } from "@/lib/types"; +import { processSteps } from "./utils/layoutEngine"; +import type { LayoutNode, Edge } from "./utils/types"; +import AgentNode from "./nodes/AgentNode"; +import UserNode from "./nodes/UserNode"; +import WorkflowGroup from "./nodes/WorkflowGroup"; +// import EdgeLayer from "./EdgeLayer"; + +/** + * Check if a node or any of its descendants has status 'in-progress' + */ +function hasProcessingDescendant(node: LayoutNode): boolean { + if (node.data.status === 'in-progress') { + return true; + } + for (const child of node.children) { + if (hasProcessingDescendant(child)) { + return true; + } + } + if (node.parallelBranches) { + for (const branch of node.parallelBranches) { + for (const branchNode of branch) { + if (hasProcessingDescendant(branchNode)) { + return true; + } + } + } + } + return false; +} + +/** + * Recursively collapse nested agents (level > 0) and recalculate their dimensions + */ +function collapseNestedAgents(node: LayoutNode, nestingLevel: number, expandedNodeIds: Set = new Set()): LayoutNode { + // Check if this node is manually expanded + const isManuallyExpanded = expandedNodeIds.has(node.id); + + // Special handling for Map/Fork nodes (pill variant with parallel branches) + // Don't collapse these - instead, flatten their parallel branches + if (node.type === 'agent' && node.data.variant === 'pill' && node.parallelBranches && node.parallelBranches.length > 0) { + // Flatten all branches into a single array of children + const flattenedChildren: LayoutNode[] = []; + for (const branch of node.parallelBranches) { + for (const child of branch) { + flattenedChildren.push(collapseNestedAgents(child, nestingLevel + 1, expandedNodeIds)); + } + } + + // Recalculate height based on flattened children + const padding = 16; + const gap = 16; + + const childrenHeight = flattenedChildren.reduce((sum, child, idx) => { + return sum + child.height + (idx < flattenedChildren.length - 1 ? gap : 0); + }, 0); + + // Height includes the pill itself (40px) + padding + children + const newHeight = 40 + padding * 2 + childrenHeight; + + return { + ...node, + children: flattenedChildren, + parallelBranches: undefined, // Clear parallel branches + height: newHeight, + }; + } + + // For regular agents at level > 0, collapse them (unless manually expanded) + if (node.type === 'agent' && nestingLevel > 0) { + if (isManuallyExpanded) { + // Node is manually expanded - process children but mark as expanded + const expandedChildren = node.children.map(child => collapseNestedAgents(child, nestingLevel + 1, expandedNodeIds)); + + // Recalculate height + const headerHeight = 50; + const padding = 16; + const gap = 16; + const childrenHeight = expandedChildren.reduce((sum, child, idx) => { + return sum + child.height + (idx < expandedChildren.length - 1 ? gap : 0); + }, 0); + const newHeight = headerHeight + padding * 2 + childrenHeight; + + return { + ...node, + children: expandedChildren, + height: newHeight, + data: { + ...node.data, + isExpanded: true, // Mark as expanded so collapse icon shows + }, + }; + } + + // Check if any children are processing before we collapse them + const childrenProcessing = hasProcessingDescendant(node); + + // Collapsed agent: just header + padding, no children + const headerHeight = 50; + const padding = 16; + const collapsedHeight = headerHeight + padding; + + return { + ...node, + children: [], + parallelBranches: undefined, + height: collapsedHeight, + data: { + ...node.data, + isCollapsed: true, + // If children were processing, mark the collapsed node as processing + hasProcessingChildren: childrenProcessing, + }, + }; + } + + // For workflow groups, collapse them entirely (unless manually expanded) + if (node.type === 'group') { + if (isManuallyExpanded) { + // Node is manually expanded - process children but mark as expanded + const expandedChildren = node.children.map(child => collapseNestedAgents(child, nestingLevel + 1, expandedNodeIds)); + + // Recalculate height (group uses 24px padding) + const padding = 24; + const gap = 16; + const childrenHeight = expandedChildren.reduce((sum, child, idx) => { + return sum + child.height + (idx < expandedChildren.length - 1 ? gap : 0); + }, 0); + const newHeight = padding * 2 + childrenHeight; + + return { + ...node, + children: expandedChildren, + height: newHeight, + data: { + ...node.data, + isExpanded: true, // Mark as expanded so collapse icon shows + }, + }; + } + + // Check if any children are processing before we collapse them + const childrenProcessing = hasProcessingDescendant(node); + + // Collapsed workflow: just header + padding, no children + const headerHeight = 50; + const padding = 16; + const collapsedHeight = headerHeight + padding; + + return { + ...node, + children: [], + parallelBranches: undefined, + height: collapsedHeight, + data: { + ...node.data, + isCollapsed: true, + // If children were processing, mark the collapsed node as processing + hasProcessingChildren: childrenProcessing, + }, + }; + } + + // For top-level nodes or non-agent nodes, process children recursively + if (node.children.length > 0) { + const collapsedChildren = node.children.map(child => collapseNestedAgents(child, nestingLevel + 1, expandedNodeIds)); + + // Recalculate height + const headerHeight = node.type === 'agent' ? 50 : 0; + const padding = node.type === 'agent' ? 16 : ((node.type as string) === 'group' ? 24 : 0); + const gap = 16; + + const childrenHeight = collapsedChildren.reduce((sum, child, idx) => { + return sum + child.height + (idx < collapsedChildren.length - 1 ? gap : 0); + }, 0); + + const newHeight = headerHeight + padding * 2 + childrenHeight; + + return { + ...node, + children: collapsedChildren, + height: newHeight, + }; + } + + // Handle parallel branches - flatten them into sequential children when collapsed + if (node.parallelBranches && node.parallelBranches.length > 0) { + // Flatten all branches into a single array of children + const flattenedChildren: LayoutNode[] = []; + for (const branch of node.parallelBranches) { + for (const child of branch) { + flattenedChildren.push(collapseNestedAgents(child, nestingLevel + 1, expandedNodeIds)); + } + } + + // Recalculate height based on flattened children + const headerHeight = node.type === 'agent' ? 50 : 0; + const padding = node.type === 'agent' ? 16 : ((node.type as string) === 'group' ? 24 : 0); + const gap = 16; + + const childrenHeight = flattenedChildren.reduce((sum, child, idx) => { + return sum + child.height + (idx < flattenedChildren.length - 1 ? gap : 0); + }, 0); + + const newHeight = headerHeight + padding * 2 + childrenHeight; + + return { + ...node, + children: flattenedChildren, + parallelBranches: undefined, // Clear parallel branches + height: newHeight, + }; + } + + return node; +} + +interface WorkflowRendererProps { + processedSteps: VisualizerStep[]; + agentNameMap: Record; + selectedStepId?: string | null; + onNodeClick?: (node: LayoutNode) => void; + onEdgeClick?: (edge: Edge) => void; + showDetail?: boolean; +} + +const WorkflowRenderer: React.FC = ({ + processedSteps, + agentNameMap, + selectedStepId, + onNodeClick, + onEdgeClick, + showDetail = true, +}) => { + const [_selectedEdgeId, setSelectedEdgeId] = useState(null); + const [expandedNodeIds, setExpandedNodeIds] = useState>(new Set()); + + // Handle expand toggle for a node + const handleExpandNode = (nodeId: string) => { + setExpandedNodeIds(prev => { + const newSet = new Set(prev); + if (newSet.has(nodeId)) { + newSet.delete(nodeId); + } else { + newSet.add(nodeId); + } + return newSet; + }); + }; + + // Process steps into layout + const baseLayoutResult = useMemo(() => { + if (!processedSteps || processedSteps.length === 0) { + return { nodes: [], edges: [], totalWidth: 800, totalHeight: 600 }; + } + + try { + return processSteps(processedSteps, agentNameMap); + } catch (error) { + console.error("[WorkflowRenderer] Error processing steps:", error); + return { nodes: [], edges: [], totalWidth: 800, totalHeight: 600 }; + } + }, [processedSteps, agentNameMap]); + + // Collapse nested agents when showDetail is false + const layoutResult = useMemo(() => { + if (showDetail) { + return baseLayoutResult; + } + + // Deep clone and collapse nodes (respecting manually expanded nodes) + const collapsedNodes = baseLayoutResult.nodes.map(node => collapseNestedAgents(node, 0, expandedNodeIds)); + return { + ...baseLayoutResult, + nodes: collapsedNodes, + }; + }, [baseLayoutResult, showDetail, expandedNodeIds]); + + const { nodes, edges: _edges, totalWidth: _totalWidth, totalHeight: _totalHeight } = layoutResult; + + // Handle node click + const handleNodeClick = (node: LayoutNode) => { + onNodeClick?.(node); + }; + + // Handle edge click - currently unused but kept for future use + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleEdgeClick = (edge: Edge) => { + setSelectedEdgeId(edge.id); + onEdgeClick?.(edge); + }; + void handleEdgeClick; // Suppress unused variable warning + + // Render a top-level node + const renderNode = (node: LayoutNode, index: number) => { + const isSelected = node.data.visualizerStepId === selectedStepId; + + const nodeProps = { + node, + isSelected, + onClick: handleNodeClick, + onChildClick: handleNodeClick, // For nested clicks + onExpand: handleExpandNode, + onCollapse: handleExpandNode, // Same handler - toggles expanded state + }; + + let component: React.ReactNode; + + switch (node.type) { + case 'agent': + component = ; + break; + case 'user': + component = ; + break; + case 'group': + component = ; + break; + default: + return null; + } + + return ( + + {component} + {/* Add connector line between nodes */} + {index < nodes.length - 1 && ( +
+ )} + + ); + }; + + if (nodes.length === 0) { + return ( +
+ {processedSteps.length > 0 ? "Processing flow data..." : "No steps to display in flow chart."} +
+ ); + } + + return ( +
+ {/* Nodes in vertical flow */} + {nodes.map((node, index) => renderNode(node, index))} +
+ ); +}; + +export default WorkflowRenderer; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/customEdges/GenericFlowEdge.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/customEdges/GenericFlowEdge.tsx deleted file mode 100644 index 69da5cf0a..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/customEdges/GenericFlowEdge.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { useState } from "react"; - -import { type EdgeProps, getBezierPath } from "@xyflow/react"; - -export interface AnimatedEdgeData { - visualizerStepId: string; - isAnimated?: boolean; - animationType?: "request" | "response" | "static"; - isSelected?: boolean; - isError?: boolean; - errorMessage?: string; -} - -const GenericFlowEdge: React.FC = ({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, markerEnd, data }) => { - const [isHovered, setIsHovered] = useState(false); - - const [edgePath] = getBezierPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition, - }); - - const getEdgeStyle = () => { - const baseStyle = { - strokeWidth: isHovered ? 3 : 2, - stroke: "var(--color-muted-foreground)", - ...style, - }; - - const edgeData = data as unknown as AnimatedEdgeData; - - // Priority: Error > Selected > Animated > Hover > Default - if (edgeData?.isError) { - return { - ...baseStyle, - stroke: isHovered ? "var(--color-error-wMain)" : "var(--color-error-w70)", - strokeWidth: isHovered ? 3 : 2, - }; - } - - if (edgeData?.isSelected) { - return { - ...baseStyle, - stroke: "#3b82f6", // same as VisualizerStepCard - strokeWidth: 3, - }; - } - - // Enhanced logic: handle both animation and hover states - if (edgeData?.isAnimated) { - return { - ...baseStyle, - stroke: isHovered ? "#1d4ed8" : "#3b82f6", - strokeWidth: isHovered ? 4 : 3, - }; - } - - // For non-animated edges, change color on hover - if (isHovered) { - return { - ...baseStyle, - stroke: "var(--edge-hover-color)", - }; - } - - return baseStyle; - }; - - const handleMouseEnter = () => setIsHovered(true); - const handleMouseLeave = () => setIsHovered(false); - - return ( - <> - {/* Invisible wider path for easier clicking */} - - {/* Visible edge path */} - - - ); -}; - -export default GenericFlowEdge; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/GenericAgentNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/GenericAgentNode.tsx deleted file mode 100644 index ab00382f5..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/GenericAgentNode.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; - -import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; - -export interface GenericNodeData extends Record { - label: string; - description?: string; - icon?: string; - subflow?: boolean; - isInitial?: boolean; - isFinal?: boolean; -} - -const GenericAgentNode: React.FC>> = ({ data }) => { - return ( -
- - - - - -
-
- {data.label} -
-
-
- ); -}; - -export default GenericAgentNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/GenericArtifactNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/GenericArtifactNode.tsx deleted file mode 100644 index dc69856d7..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/GenericArtifactNode.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; - -import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; - -import type { GenericNodeData } from "./GenericAgentNode"; - -export type ArtifactNodeType = Node; - -const ArtifactNode: React.FC> = ({ data, id }) => { - return ( -
- -
-
-
-
{data.label}
-
-
-
- ); -}; - -export default ArtifactNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/GenericToolNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/GenericToolNode.tsx deleted file mode 100644 index 2f32311ab..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/GenericToolNode.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react"; - -import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; - -import type { GenericNodeData } from "./GenericAgentNode"; - -export type GenericToolNodeType = Node; - -const GenericToolNode: React.FC> = ({ data, id }) => { - const getStatusColor = () => { - switch (data.status) { - case "completed": - return "bg-green-500"; - case "in-progress": - return "bg-blue-500"; - case "error": - return "bg-red-500"; - case "started": - return "bg-yellow-400"; - case "idle": - default: - return "bg-cyan-500"; - } - }; - - return ( -
- -
-
-
- {data.label} -
-
- - -
- ); -}; - -export default GenericToolNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/LLMNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/LLMNode.tsx deleted file mode 100644 index 0a492abdb..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/LLMNode.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; - -import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; - -import type { GenericNodeData } from "./GenericAgentNode"; - -export type LLMNodeType = Node; - -const LLMNode: React.FC> = ({ data }) => { - const getStatusColor = () => { - switch (data.status) { - case "completed": - return "bg-green-500"; - case "in-progress": - return "bg-blue-500"; - case "error": - return "bg-red-500"; - case "started": - return "bg-yellow-400"; - case "idle": - default: - return "bg-teal-500"; - } - }; - - return ( -
- -
-
-
{data.label}
-
- -
- ); -}; - -export default LLMNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/OrchestratorAgentNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/OrchestratorAgentNode.tsx deleted file mode 100644 index f2519a0dd..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/OrchestratorAgentNode.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from "react"; - -import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; - -import type { GenericNodeData } from "./GenericAgentNode"; - -export type OrchestratorAgentNodeType = Node; - -const OrchestratorAgentNode: React.FC> = ({ data }) => { - return ( -
- - - - - -
-
- {data.label} -
-
-
- ); -}; - -export default OrchestratorAgentNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/UserNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/UserNode.tsx deleted file mode 100644 index 800918b48..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/customNodes/UserNode.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react"; -import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; -import type { GenericNodeData } from "./GenericAgentNode"; - -export interface UserNodeData extends GenericNodeData { - isTopNode?: boolean; // true if created by handleUserRequest - isBottomNode?: boolean; // true if created by createNewUserNodeAtBottom -} - -export type UserNodeType = Node; - -const UserNode: React.FC> = ({ data }) => { - const getStatusColor = () => { - switch (data.status) { - case "completed": - return "bg-green-500"; - case "in-progress": - return "bg-blue-500"; - case "error": - return "bg-red-500"; - case "started": - return "bg-yellow-400"; - case "idle": - default: - return "bg-purple-500"; - } - }; - - return ( -
- {data.isTopNode && } - {data.isBottomNode && } - {!data.isTopNode && !data.isBottomNode && } -
-
-
{data.label}
-
-
- ); -}; - -export default UserNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/edgeAnimationService.ts b/client/webui/frontend/src/lib/components/activities/FlowChart/edgeAnimationService.ts deleted file mode 100644 index d310aeb01..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/edgeAnimationService.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { VisualizerStep } from "@/lib/types"; - -export interface EdgeAnimationState { - isAnimated: boolean; - animationType: "request" | "response" | "static"; -} - -export class EdgeAnimationService { - /** - * Simplified animation logic: Only animate agent-to-tool request edges - * until their corresponding response is received. - */ - public getEdgeAnimationState(edgeStepId: string, upToStep: number, allSteps: VisualizerStep[]): EdgeAnimationState { - const currentStep = allSteps.find(step => step.id === edgeStepId); - if (!currentStep) { - return { isAnimated: false, animationType: "static" }; - } - - // Only animate agent-to-tool interactions - if (!this.isAgentToToolRequest(currentStep)) { - return { isAnimated: false, animationType: "static" }; - } - - // Check if this request has been completed by looking at steps up to current point - const stepsUpToPoint = allSteps.slice(0, upToStep + 1); - const isCompleted = this.hasMatchingResponse(currentStep, stepsUpToPoint); - - if (isCompleted) { - return { isAnimated: false, animationType: "static" }; - } - - return { - isAnimated: true, - animationType: "request", - }; - } - - /** - * Check if a step represents an agent-to-tool request that should be animated - */ - private isAgentToToolRequest(step: VisualizerStep): boolean { - switch (step.type) { - case "AGENT_LLM_CALL": - // Agent calling LLM (lane 2 to lane 3) - return true; - - case "AGENT_TOOL_INVOCATION_START": { - // Only animate if it's a tool call, not a peer delegation - const isPeerDelegation = step.data.toolDecision?.isPeerDelegation || step.data.toolInvocationStart?.isPeerInvocation || (step.target && step.target.startsWith("peer_")); - return !isPeerDelegation; - } - - default: - return false; - } - } - - /** - * Check if there's a matching response for the given request step - */ - private hasMatchingResponse(requestStep: VisualizerStep, stepsToCheck: VisualizerStep[]): boolean { - switch (requestStep.type) { - case "AGENT_LLM_CALL": - return this.hasLLMResponse(requestStep, stepsToCheck); - - case "AGENT_TOOL_INVOCATION_START": - return this.hasToolResponse(requestStep, stepsToCheck); - - default: - return false; - } - } - - /** - * Check if there's an LLM response for the given LLM call - */ - private hasLLMResponse(llmCallStep: VisualizerStep, stepsToCheck: VisualizerStep[]): boolean { - const callTimestamp = new Date(llmCallStep.timestamp).getTime(); - const callingAgent = llmCallStep.source; - - // Look for any step that comes after this LLM call from the same agent - // This indicates the LLM call has completed and the agent is proceeding - return stepsToCheck.some(step => { - const stepTimestamp = new Date(step.timestamp).getTime(); - - if (stepTimestamp < callTimestamp) return false; - - // Check for direct LLM responses to the agent - const isDirectLLMResponse = (step.type === "AGENT_LLM_RESPONSE_TOOL_DECISION" || step.type === "AGENT_LLM_RESPONSE_TO_AGENT") && step.target === callingAgent; - - // Check for any subsequent action by the same agent (indicates LLM call completed) - const isSubsequentAgentAction = step.source === callingAgent && (step.type === "AGENT_TOOL_INVOCATION_START" || step.type === "TASK_COMPLETED"); - const isPeerResponse = step.type === "AGENT_TOOL_EXECUTION_RESULT" && step.data.toolResult?.isPeerResponse; - - return isDirectLLMResponse || isSubsequentAgentAction || isPeerResponse; - }); - } - - /** - * Check if there's a tool response for the given tool invocation - */ - private hasToolResponse(toolCallStep: VisualizerStep, stepsToCheck: VisualizerStep[]): boolean { - const callTimestamp = new Date(toolCallStep.timestamp).getTime(); - const toolName = toolCallStep.target; - const callingAgent = toolCallStep.source; - - return stepsToCheck.some(step => { - const stepTimestamp = new Date(step.timestamp).getTime(); - if (stepTimestamp < callTimestamp) return false; - - return step.type === "AGENT_TOOL_EXECUTION_RESULT" && step.source === toolName && step.target === callingAgent; - }); - } - - public isRequestStep(step: VisualizerStep): boolean { - return this.isAgentToToolRequest(step); - } - - public isResponseStep(step: VisualizerStep): boolean { - return ["AGENT_TOOL_EXECUTION_RESULT", "AGENT_LLM_RESPONSE_TOOL_DECISION", "AGENT_LLM_RESPONSE_TO_AGENT"].includes(step.type); - } -} diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/index.ts b/client/webui/frontend/src/lib/components/activities/FlowChart/index.ts new file mode 100644 index 000000000..41db74874 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/index.ts @@ -0,0 +1,5 @@ +export { default as FlowChartPanel } from "./FlowChartPanel"; +export { default as NodeDetailsCard } from "./NodeDetailsCard"; +export type { LayoutNode, Edge } from "./utils/types"; +export type { NodeDetails } from "./utils/nodeDetailsHelper"; +export { findNodeDetails } from "./utils/nodeDetailsHelper"; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/AgentNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/AgentNode.tsx new file mode 100644 index 000000000..5fdc6658a --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/AgentNode.tsx @@ -0,0 +1,292 @@ +import { Fragment, type FC } from "react"; +import { Bot, Maximize2, Minimize2 } from "lucide-react"; +import type { LayoutNode } from "../utils/types"; +import LLMNode from "./LLMNode"; +import ToolNode from "./ToolNode"; +import SwitchNode from "./SwitchNode"; +import LoopNode from "./LoopNode"; +import WorkflowGroup from "./WorkflowGroup"; + + +interface AgentNodeProps { + node: LayoutNode; + isSelected?: boolean; + onClick?: (node: LayoutNode) => void; + onChildClick?: (child: LayoutNode) => void; + onExpand?: (nodeId: string) => void; + onCollapse?: (nodeId: string) => void; +} + +const AgentNode: FC = ({ node, isSelected, onClick, onChildClick, onExpand, onCollapse }) => { + // Render a child node recursively + const renderChild = (child: LayoutNode) => { + const childProps = { + node: child, + onClick: onChildClick, + onExpand, + onCollapse, + }; + + switch (child.type) { + case 'agent': + // Recursive! + return ; + case 'llm': + return ; + case 'tool': + return ; + case 'switch': + return ; + case 'loop': + return ; + case 'group': + return ; + case 'parallelBlock': + // Render parallel block - children displayed side-by-side with bounding box + return ( +
+ {child.children.map((parallelChild) => renderChild(parallelChild))} +
+ ); + default: + return null; + } + }; + + // Pill variant for Start/Finish/Join/Map/Fork nodes + if (node.data.variant === 'pill') { + const opacityClass = node.data.isSkipped ? "opacity-50" : ""; + const borderStyleClass = node.data.isSkipped ? "border-dashed" : "border-solid"; + const hasParallelBranches = node.parallelBranches && node.parallelBranches.length > 0; + const hasChildren = node.children && node.children.length > 0; + const isError = node.data.status === 'error'; + + // Color classes based on error status + const pillColorClasses = isError + ? "border-red-500 bg-red-50 text-red-900 dark:border-red-400 dark:bg-red-900/50 dark:text-red-100" + : "border-indigo-500 bg-indigo-50 text-indigo-900 dark:border-indigo-400 dark:bg-indigo-900/50 dark:text-indigo-100"; + + // If it's a simple pill (no parallel branches and no children), render compact version + if (!hasParallelBranches && !hasChildren) { + return ( +
{ + e.stopPropagation(); + onClick?.(node); + }} + title={node.data.description} + > +
+
{node.data.label}
+
+
+ ); + } + + // Map/Fork pill with sequential children (flattened from parallel branches when detail is off) + if (hasChildren && !hasParallelBranches) { + return ( +
+ {/* Pill label */} +
{ + e.stopPropagation(); + onClick?.(node); + }} + title={node.data.description} + > +
+
{node.data.label}
+
+
+ + {/* Connector line to children */} +
+ + {/* Sequential children below */} + {node.children.map((child, index) => ( + + {renderChild(child)} + {/* Connector line to next child */} + {index < node.children.length - 1 && ( +
+ )} + + ))} +
+ ); + } + + // Map/Fork pill with parallel branches + return ( +
+ {/* Pill label */} +
{ + e.stopPropagation(); + onClick?.(node); + }} + title={node.data.description} + > +
+
{node.data.label}
+
+
+ + {/* Connector line to branches */} +
+ + {/* Parallel branches below */} +
+
+ {node.parallelBranches!.map((branch, branchIndex) => ( +
+ {branch.map((child, index) => ( + + {renderChild(child)} + {/* Connector line to next child in branch */} + {index < branch.length - 1 && ( +
+ )} + + ))} +
+ ))} +
+
+
+ ); + } + + // Regular agent node with children + const opacityClass = node.data.isSkipped ? "opacity-50" : ""; + const borderStyleClass = node.data.isSkipped ? "border-dashed" : "border-solid"; + // Show effect if this node is processing OR if children are hidden but processing + const isProcessing = node.data.status === "in-progress" || node.data.hasProcessingChildren; + + const haloClass = isProcessing ? 'processing-halo' : ''; + + const isCollapsed = node.data.isCollapsed; + + // Check if this is an expanded node (manually expanded from collapsed state) + const isExpanded = node.data.isExpanded; + + return ( +
+ {/* Collapse icon - top right, only show on hover when expanded */} + {isExpanded && onCollapse && ( + + { + e.stopPropagation(); + onCollapse(node.id); + }} + /> + + )} + {/* Expand icon - top right, only show on hover when collapsed */} + {isCollapsed && onExpand && ( + + { + e.stopPropagation(); + onExpand(node.id); + }} + /> + + )} + {/* Header */} +
{ + e.stopPropagation(); + onClick?.(node); + }} + title={node.data.description} + > +
+ +
+ {node.data.label} +
+
+
+ + {/* Content - Children with inline connectors */} + {node.children.length > 0 && ( +
+ {node.children.map((child, index) => ( + + {renderChild(child)} + {/* Connector line to next child */} + {index < node.children.length - 1 && ( +
+ )} + + ))} +
+ )} + + {/* Parallel Branches */} + {node.parallelBranches && node.parallelBranches.length > 0 && ( +
+
+ {node.parallelBranches.map((branch, branchIndex) => ( +
+ {branch.map((child, index) => ( + + {renderChild(child)} + {/* Connector line to next child in branch */} + {index < branch.length - 1 && ( +
+ )} + + ))} +
+ ))} +
+
+ )} +
+ ); +}; + +export default AgentNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/LLMNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/LLMNode.tsx new file mode 100644 index 000000000..444e73e23 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/LLMNode.tsx @@ -0,0 +1,42 @@ +import type { FC } from "react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/lib/components/ui"; +import type { LayoutNode } from "../utils/types"; + +interface LLMNodeProps { + node: LayoutNode; + isSelected?: boolean; + onClick?: (node: LayoutNode) => void; +} + +const LLMNode: FC = ({ node, isSelected, onClick }) => { + const isProcessing = node.data.status === "in-progress"; + const haloClass = isProcessing ? 'processing-halo' : ''; + + return ( + + +
{ + e.stopPropagation(); + onClick?.(node); + }} + > +
+ {node.data.label} +
+
+
+ {node.data.description && ( + {node.data.description} + )} +
+ ); +}; + +export default LLMNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/LoopNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/LoopNode.tsx new file mode 100644 index 000000000..a1492d48f --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/LoopNode.tsx @@ -0,0 +1,159 @@ +import { Fragment, type FC } from "react"; +import type { LayoutNode } from "../utils/types"; +import AgentNode from "./AgentNode"; + +interface LoopNodeProps { + node: LayoutNode; + isSelected?: boolean; + onClick?: (node: LayoutNode) => void; + onChildClick?: (child: LayoutNode) => void; + onExpand?: (nodeId: string) => void; + onCollapse?: (nodeId: string) => void; +} + +const LoopNode: FC = ({ node, isSelected, onClick, onChildClick, onExpand, onCollapse }) => { + const getStatusColor = () => { + switch (node.data.status) { + case "completed": + return "bg-teal-100 border-teal-500 dark:bg-teal-900/30 dark:border-teal-500"; + case "in-progress": + return "bg-blue-100 border-blue-500 dark:bg-blue-900/30 dark:border-blue-500"; + case "error": + return "bg-red-100 border-red-500 dark:bg-red-900/30 dark:border-red-500"; + default: + return "bg-gray-100 border-gray-400 dark:bg-gray-800 dark:border-gray-600"; + } + }; + + const currentIteration = node.data.currentIteration ?? 0; + const maxIterations = node.data.maxIterations ?? 100; + const hasChildren = node.children && node.children.length > 0; + + // Render a child node (loop iterations are agent nodes) + const renderChild = (child: LayoutNode) => { + const childProps = { + node: child, + onClick: onChildClick, + onChildClick: onChildClick, + onExpand, + onCollapse, + }; + + switch (child.type) { + case 'agent': + return ; + default: + // Loop children are typically agents, but handle other types if needed + return null; + } + }; + + // If the loop has children (iterations), render as a container + if (hasChildren) { + return ( +
+ {/* Loop Label with icon - clickable */} +
{ + e.stopPropagation(); + onClick?.(node); + }} + title={`Loop: ${node.data.condition || 'while condition'} (max ${maxIterations})`} + > + {/* Loop Arrow Icon */} + + + + {node.data.label} +
+ + {/* Children (loop iterations) with inline connectors */} +
+ {node.children.map((child, index) => ( + + {/* Iteration label */} +
+ Iteration {index + 1} +
+ {renderChild(child)} + {/* Connector line to next child */} + {index < node.children.length - 1 && ( +
+ )} + + ))} +
+
+ ); + } + + // No children yet - render as compact badge + return ( +
{ + e.stopPropagation(); + onClick?.(node); + }} + title={node.data.description || `Loop: ${node.data.condition || 'while condition'} (max ${maxIterations})`} + > + {/* Stadium/Pill shape with loop indicator */} +
+ {/* Loop Arrow Icon */} + + + + + {/* Content */} +
+
+ {node.data.label} +
+
+
+ + {/* Iteration Counter (if in progress) */} + {node.data.status === 'in-progress' && currentIteration > 0 && ( +
+ Iteration {currentIteration} +
+ )} +
+ ); +}; + +export default LoopNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/MapNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/MapNode.tsx new file mode 100644 index 000000000..8b1f0a9d0 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/MapNode.tsx @@ -0,0 +1,181 @@ +import { Fragment, useMemo, type FC } from "react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/lib/components/ui"; +import type { LayoutNode } from "../utils/types"; +import AgentNode from "./AgentNode"; + +interface MapNodeProps { + node: LayoutNode; + isSelected?: boolean; + onClick?: (node: LayoutNode) => void; + onChildClick?: (child: LayoutNode) => void; + onExpand?: (nodeId: string) => void; + onCollapse?: (nodeId: string) => void; +} + +const MapNode: FC = ({ node, isSelected, onClick, onChildClick, onExpand, onCollapse }) => { + const getStatusColor = () => { + switch (node.data.status) { + case "completed": + return "bg-indigo-100 border-indigo-500 dark:bg-indigo-900/30 dark:border-indigo-500"; + case "in-progress": + return "bg-blue-100 border-blue-500 dark:bg-blue-900/30 dark:border-blue-500"; + case "error": + return "bg-red-100 border-red-500 dark:bg-red-900/30 dark:border-red-500"; + default: + return "bg-gray-100 border-gray-400 dark:bg-gray-800 dark:border-gray-600"; + } + }; + + // Group children by iterationIndex to create branches + const branches = useMemo(() => { + const branchMap = new Map(); + for (const child of node.children) { + const iterationIndex = child.data.iterationIndex ?? 0; + if (!branchMap.has(iterationIndex)) { + branchMap.set(iterationIndex, []); + } + branchMap.get(iterationIndex)!.push(child); + } + // Sort by iteration index and return as array of arrays + return Array.from(branchMap.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([, children]) => children); + }, [node]); + + const hasChildren = branches.length > 0; + const label = 'Map'; + const colorClass = "border-indigo-400 bg-indigo-50/30 dark:border-indigo-600 dark:bg-indigo-900/20"; + const labelColorClass = "text-indigo-600 dark:text-indigo-400 border-indigo-300 dark:border-indigo-700 hover:bg-indigo-50 dark:hover:bg-indigo-900/50"; + const connectorColor = "bg-indigo-400 dark:bg-indigo-600"; + + // Render a child node (iterations are agent nodes) + const renderChild = (child: LayoutNode) => { + const childProps = { + node: child, + onClick: onChildClick, + onChildClick: onChildClick, + onExpand, + onCollapse, + }; + + switch (child.type) { + case 'agent': + return ; + default: + return null; + } + }; + + // If the node has children, render as a container with parallel branches + if (hasChildren) { + return ( +
+ {/* Label with icon - clickable */} + + +
{ + e.stopPropagation(); + onClick?.(node); + }} + > + {/* Parallel/Branch Icon */} + + + + {node.data.label || label} +
+
+ {`${label}: ${branches.length} parallel branches`} +
+ + {/* Parallel branches displayed side-by-side */} +
+ {branches.map((branch, branchIndex) => ( +
+ {/* Branch children */} + {branch.map((child, childIndex) => ( + + {renderChild(child)} + {/* Connector line to next child in same branch */} + {childIndex < branch.length - 1 && ( +
+ )} + + ))} +
+ ))} +
+
+ ); + } + + // No parallel branches yet - render as compact badge + const badgeTooltip = node.data.description || `Map: Waiting for items...`; + + return ( + + +
{ + e.stopPropagation(); + onClick?.(node); + }} + > + {/* Stadium/Pill shape */} +
+ {/* Parallel Icon */} + + + + + {/* Content */} +
+
+ {node.data.label || label} +
+
+
+
+
+ {badgeTooltip} +
+ ); +}; + +export default MapNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/SwitchNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/SwitchNode.tsx new file mode 100644 index 000000000..5ad41bc95 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/SwitchNode.tsx @@ -0,0 +1,72 @@ +import type { FC } from "react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/lib/components/ui"; +import type { LayoutNode } from "../utils/types"; + +interface SwitchNodeProps { + node: LayoutNode; + isSelected?: boolean; + onClick?: (node: LayoutNode) => void; +} + +const SwitchNode: FC = ({ node, isSelected, onClick }) => { + const getStatusColor = () => { + switch (node.data.status) { + case "completed": + return "bg-purple-100 border-purple-500 dark:bg-purple-900/30 dark:border-purple-500"; + case "in-progress": + return "bg-blue-100 border-blue-500 dark:bg-blue-900/30 dark:border-blue-500"; + case "error": + return "bg-red-100 border-red-500 dark:bg-red-900/30 dark:border-red-500"; + default: + return "bg-gray-100 border-gray-400 dark:bg-gray-800 dark:border-gray-600"; + } + }; + + const casesCount = node.data.cases?.length || 0; + const hasDefault = !!node.data.defaultBranch; + + // Build tooltip with selected branch info + const baseTooltip = node.data.description || `Switch with ${casesCount} case${casesCount !== 1 ? 's' : ''}${hasDefault ? ' + default' : ''}`; + const tooltip = node.data.selectedBranch + ? `${baseTooltip}\nSelected: ${node.data.selectedBranch}` + : baseTooltip; + + return ( + + +
{ + e.stopPropagation(); + onClick?.(node); + }} + > + {/* Diamond Shape using rotation - same as conditional */} +
+ + {/* Content (unrotated) */} +
+
+ {/* Show selected branch when completed, otherwise show label */} + {node.data.selectedBranch || node.data.label} +
+ {/* Show case count only when not yet completed */} + {!node.data.selectedBranch && ( +
+ {casesCount} case{casesCount !== 1 ? 's' : ''} +
+ )} +
+
+ + {tooltip} + + ); +}; + +export default SwitchNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/ToolNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/ToolNode.tsx new file mode 100644 index 000000000..7225d1fa9 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/ToolNode.tsx @@ -0,0 +1,53 @@ +import type { FC } from "react"; +import { FileText, Wrench } from "lucide-react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/lib/components/ui"; +import type { LayoutNode } from "../utils/types"; + +interface ToolNodeProps { + node: LayoutNode; + isSelected?: boolean; + onClick?: (node: LayoutNode) => void; +} + +const ToolNode: FC = ({ node, isSelected, onClick }) => { + const isProcessing = node.data.status === "in-progress"; + const haloClass = isProcessing ? 'processing-halo' : ''; + const artifactCount = node.data.createdArtifacts?.length || 0; + + return ( + + +
{ + e.stopPropagation(); + onClick?.(node); + }} + > +
+ +
{node.data.label}
+ {artifactCount > 0 && ( + + + + + {artifactCount} + + + {`${artifactCount} ${artifactCount === 1 ? 'artifact' : 'artifacts'} created`} + + )} +
+
+
+ {node.data.description && ( + {node.data.description} + )} +
+ ); +}; + +export default ToolNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/UserNode.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/UserNode.tsx new file mode 100644 index 000000000..941dd77ba --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/UserNode.tsx @@ -0,0 +1,34 @@ +import type { FC } from "react"; +import { User } from "lucide-react"; +import type { LayoutNode } from "../utils/types"; + +interface UserNodeProps { + node: LayoutNode; + isSelected?: boolean; + onClick?: (node: LayoutNode) => void; +} + +const UserNode: FC = ({ node, isSelected, onClick }) => { + return ( +
{ + e.stopPropagation(); + onClick?.(node); + }} + > +
+ +
{node.data.label}
+
+
+ ); +}; + +export default UserNode; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/WorkflowGroup.tsx b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/WorkflowGroup.tsx new file mode 100644 index 000000000..511c79106 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/nodes/WorkflowGroup.tsx @@ -0,0 +1,417 @@ +import React, { useRef, useState, useLayoutEffect, useEffect, useCallback } from "react"; +import { Workflow, Maximize2, Minimize2 } from "lucide-react"; +import type { LayoutNode } from "../utils/types"; +import AgentNode from "./AgentNode"; +import SwitchNode from "./SwitchNode"; +import LoopNode from "./LoopNode"; +import MapNode from "./MapNode"; + +interface WorkflowGroupProps { + node: LayoutNode; + isSelected?: boolean; + onClick?: (node: LayoutNode) => void; + onChildClick?: (child: LayoutNode) => void; + onExpand?: (nodeId: string) => void; + onCollapse?: (nodeId: string) => void; +} + +interface BezierPath { + id: string; + d: string; +} + +/** + * Generate a cubic bezier path from source bottom-center to target top-center + * The curve uses a constant control offset at the source for a consistent departure curve, + * and a scaled control offset at the target to create long vertical sections for longer distances. + * + * @param scale - The current zoom scale factor (to convert screen coordinates to SVG coordinates) + */ +function generateBezierPath( + sourceRect: DOMRect, + targetRect: DOMRect, + containerRect: DOMRect, + scale: number = 1 +): string { + // Source: bottom center of the source element + // Divide by scale to convert from screen coordinates (affected by zoom) to SVG coordinates + const x1 = (sourceRect.left + sourceRect.width / 2 - containerRect.left) / scale; + const y1 = (sourceRect.bottom - containerRect.top) / scale; + + // Target: top center of the target element + const x2 = (targetRect.left + targetRect.width / 2 - containerRect.left) / scale; + const y2 = (targetRect.top - containerRect.top) / scale; + + // Control points for a curve with vertical start and end + const verticalDistance = Math.abs(y2 - y1); + + // Target control point: constant offset for consistent curve at arrival + const targetControlOffset = 40; + + // Source control point: extends far down to create a very long vertical section + // Using 90% of the distance creates an almost straight drop from the source + const sourceControlOffset = Math.max(verticalDistance * 1.0, 40); + + // Control point 1: directly below source (same x) for vertical start + const cx1 = x1; + const cy1 = y1 + sourceControlOffset; + + // Control point 2: directly above target (same x) for vertical end + const cx2 = x2; + const cy2 = y2 - targetControlOffset; + + return `M ${x1},${y1} C ${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`; +} + +const WorkflowGroup: React.FC = ({ node, isSelected, onClick, onChildClick, onExpand, onCollapse }) => { + const containerRef = useRef(null); + const [bezierPaths, setBezierPaths] = useState([]); + const [resizeCounter, setResizeCounter] = useState(0); + + const isCollapsed = node.data.isCollapsed; + const isExpanded = node.data.isExpanded; + const isProcessing = node.data.hasProcessingChildren; + const haloClass = isProcessing ? 'processing-halo' : ''; + + // Function to calculate bezier paths + const calculateBezierPaths = useCallback(() => { + if (!containerRef.current || isCollapsed) { + setBezierPaths([]); + return; + } + + const container = containerRef.current; + const containerRect = container.getBoundingClientRect(); + + // Calculate the current zoom scale by comparing the visual size (getBoundingClientRect) + // with the actual size (offsetWidth). When zoomed, the visual size changes but offsetWidth stays the same. + const scale = containerRect.width / container.offsetWidth; + + const paths: BezierPath[] = []; + + // Find all parallel blocks and their preceding/following nodes + const parallelBlocks = container.querySelectorAll('[data-parallel-block]'); + + parallelBlocks.forEach((blockEl) => { + const blockId = blockEl.getAttribute('data-parallel-block'); + const precedingNodeId = blockEl.getAttribute('data-preceding-node'); + const followingNodeId = blockEl.getAttribute('data-following-node'); + + // Draw lines FROM preceding node TO branch starts + if (precedingNodeId) { + const precedingWrapper = container.querySelector(`[data-node-id="${precedingNodeId}"]`); + if (precedingWrapper) { + const precedingEl = precedingWrapper.firstElementChild || precedingWrapper; + const precedingRect = precedingEl.getBoundingClientRect(); + + const branchStartNodes = blockEl.querySelectorAll('[data-branch-start="true"]'); + branchStartNodes.forEach((branchStartEl, index) => { + const targetEl = branchStartEl.firstElementChild || branchStartEl; + const targetRect = targetEl.getBoundingClientRect(); + const pathD = generateBezierPath(precedingRect, targetRect, containerRect, scale); + + paths.push({ + id: `${blockId}-start-${index}`, + d: pathD, + }); + }); + } + } + + // Draw lines FROM branch ends TO following node + if (followingNodeId) { + const followingWrapper = container.querySelector(`[data-node-id="${followingNodeId}"]`); + if (followingWrapper) { + const followingEl = followingWrapper.firstElementChild || followingWrapper; + const followingRect = followingEl.getBoundingClientRect(); + + const branchEndNodes = blockEl.querySelectorAll('[data-branch-end="true"]'); + branchEndNodes.forEach((branchEndEl, index) => { + const sourceEl = branchEndEl.firstElementChild || branchEndEl; + const sourceRect = sourceEl.getBoundingClientRect(); + const pathD = generateBezierPath(sourceRect, followingRect, containerRect, scale); + + paths.push({ + id: `${blockId}-end-${index}`, + d: pathD, + }); + }); + } + } + }); + + setBezierPaths(paths); + }, [isCollapsed]); + + // Calculate bezier paths after render + useLayoutEffect(() => { + calculateBezierPaths(); + }, [node.children, isCollapsed, resizeCounter, calculateBezierPaths]); + + // Use ResizeObserver to detect when children expand/collapse (changes their size) + useEffect(() => { + if (!containerRef.current || isCollapsed) return; + + const resizeObserver = new ResizeObserver(() => { + // Trigger recalculation by incrementing counter + setResizeCounter(c => c + 1); + }); + + // Observe the container and all nodes within it + resizeObserver.observe(containerRef.current); + const nodes = containerRef.current.querySelectorAll('[data-node-id]'); + nodes.forEach(node => resizeObserver.observe(node)); + + return () => resizeObserver.disconnect(); + }, [node.children, isCollapsed]); + + // Use MutationObserver to detect zoom/pan changes from react-zoom-pan-pinch + // The transform is applied to ancestor elements, so we watch for style changes + useEffect(() => { + if (!containerRef.current || isCollapsed) return; + + // Find the TransformComponent wrapper by looking for an ancestor with transform style + let transformedParent: Element | null = containerRef.current.parentElement; + while (transformedParent && !transformedParent.hasAttribute('style')) { + transformedParent = transformedParent.parentElement; + } + + if (!transformedParent) return; + + const mutationObserver = new MutationObserver((mutations) => { + // Check if any mutation is a style change (which includes transform changes) + const hasStyleChange = mutations.some(m => m.attributeName === 'style'); + if (hasStyleChange) { + setResizeCounter(c => c + 1); + } + }); + + // Observe style attribute changes on the transformed parent and its ancestors + // (react-zoom-pan-pinch may apply transforms at different levels) + let current: Element | null = transformedParent; + while (current && current !== document.body) { + mutationObserver.observe(current, { attributes: true, attributeFilter: ['style'] }); + current = current.parentElement; + } + + return () => mutationObserver.disconnect(); + }, [isCollapsed]); + + // Render a child node with data attributes for connector calculation + const renderChild = (child: LayoutNode, precedingNodeId?: string, followingNodeId?: string): React.ReactNode => { + const childProps = { + node: child, + onClick: onChildClick, + onExpand, + onCollapse, + }; + + switch (child.type) { + case 'agent': + return ( +
+ +
+ ); + case 'switch': + return ( +
+ +
+ ); + case 'loop': + return ( +
+ +
+ ); + case 'map': + return ( +
+ +
+ ); + case 'group': + // Nested workflow group - render recursively + return ( +
+ +
+ ); + case 'parallelBlock': { + // Group children by iterationIndex (branch index) for proper chain visualization + const branches = new Map(); + for (const parallelChild of child.children) { + const branchIdx = parallelChild.data.iterationIndex ?? 0; + if (!branches.has(branchIdx)) { + branches.set(branchIdx, []); + } + branches.get(branchIdx)!.push(parallelChild); + } + + // Sort branches by index + const sortedBranches = Array.from(branches.entries()).sort((a, b) => a[0] - b[0]); + + // Render parallel block - branches side-by-side, nodes within each branch stacked vertically + // Container is invisible - connectors are drawn via SVG bezier paths + return ( +
+ {sortedBranches.map(([branchIdx, branchChildren]) => ( +
+ {branchChildren.map((branchChild, nodeIdx) => ( + +
+ {renderChild(branchChild)} +
+ {/* Connector line to next node in branch */} + {nodeIdx < branchChildren.length - 1 && ( +
+ )} + + ))} +
+ ))} +
+ ); + } + default: + return null; + } + }; + + // Collapsed view - similar to collapsed agent but with workflow styling + if (isCollapsed) { + return ( +
+ {/* Expand icon - top right, only show on hover */} + {onExpand && ( + + { + e.stopPropagation(); + onExpand(node.id); + }} + /> + + )} + {/* Header */} +
{ + e.stopPropagation(); + onClick?.(node); + }} + title={node.data.description || "Click to view workflow details"} + > +
+ +
+ {node.data.label} +
+
+
+
+ ); + } + + // Full expanded view + return ( +
+ {/* SVG overlay for bezier connectors */} + {bezierPaths.length > 0 && ( + + {bezierPaths.map((path) => ( + + ))} + + )} + + {/* Collapse icon - top right, only show on hover when expanded */} + {isExpanded && onCollapse && ( + + { + e.stopPropagation(); + onCollapse(node.id); + }} + /> + + )} + {/* Label - clickable */} + {node.data.label && ( +
{ + e.stopPropagation(); + onClick?.(node); + }} + title="Click to view workflow details" + > + + {node.data.label} +
+ )} + + {/* Children with inline connectors */} +
+ {node.children.map((child, index) => { + // Track the preceding and following nodes for parallel blocks + const precedingNode = index > 0 ? node.children[index - 1] : null; + const precedingNodeId = precedingNode?.id; + const followingNode = index < node.children.length - 1 ? node.children[index + 1] : null; + const followingNodeId = followingNode?.id; + + return ( + + {renderChild(child, precedingNodeId, followingNodeId)} + {/* Connector line to next child (only if current is not parallelBlock and next is not parallelBlock) */} + {index < node.children.length - 1 && + child.type !== 'parallelBlock' && + node.children[index + 1].type !== 'parallelBlock' && ( +
+ )} + + ); + })} +
+
+ ); +}; + +export default WorkflowGroup; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/taskToFlowData.helpers.ts b/client/webui/frontend/src/lib/components/activities/FlowChart/taskToFlowData.helpers.ts deleted file mode 100644 index 60b54428b..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/taskToFlowData.helpers.ts +++ /dev/null @@ -1,821 +0,0 @@ -import type { Node, Edge } from "@xyflow/react"; -import type { VisualizerStep } from "@/lib/types"; -import { EdgeAnimationService } from "./edgeAnimationService"; - -// Helper function to resolve agent name to display name -export function resolveAgentDisplayName(agentName: string, agentNameMap?: Record): string { - return agentNameMap?.[agentName] || agentName; -} - -export interface NodeUpdateData { - label?: string; - status?: string; - isInitial?: boolean; - isFinal?: boolean; - description?: string; -} - -export interface LayoutContext { - currentY: number; - mainX: number; - subflowXOffset: number; - yIncrement: number; - subflowDepth: number; - agentYPositions: Map; // Store Y position of agent nodes for alignment - orchestratorCopyCount: number; // For unique IDs of duplicated orchestrator nodes - userGatewayCopyCount: number; // For unique IDs of duplicated user/gateway nodes - llmNodeXOffset: number; // Horizontal offset for LLM nodes from their calling agent - agentHorizontalSpacing: number; // Horizontal spacing between sibling agent nodes - agentsByLevel: Map; // Store agents at each Y level for horizontal alignment -} - -// Layout Management Interfaces -export interface NodeInstance { - id: string; - xPosition?: number; - yPosition: number; - height: number; - width: number; - functionCallId?: string; // The functionCallId that initiated this node -} - -export interface PhaseContext { - id: string; - orchestratorAgent: NodeInstance; - userNodes: NodeInstance[]; - subflows: SubflowContext[]; - // Stores all tool instances for tools called directly by this phase's orchestrator - toolInstances: NodeInstance[]; - currentToolYOffset: number; // Tracks Y offset for next tool called by orchestrator in this phase - maxY: number; // Max Y reached by elements directly in this phase (orchestrator, its tools) -} - -export interface SubflowContext { - id: string; // Corresponds to subTaskId - functionCallId: string; // The functionCallId that initiated this subflow - isParallel: boolean; // True if this subflow is part of a parallel execution - peerAgent: NodeInstance; // Stores absolute position of the peer agent - groupNode: NodeInstance; // Stores absolute position of the group - // Stores all tool instances for tools called by this subflow's peer agent - toolInstances: NodeInstance[]; - currentToolYOffset: number; // Tracks Y offset for next tool called by peer agent in this subflow - maxY: number; // Max Y (absolute) reached by elements within this subflow group - maxContentXRelative: number; // Max X reached by content relative to group's left edge - callingPhaseId: string; - // Parent context tracking for nested parallel flows - parentSubflowId?: string; // ID of the parent subflow (if nested) - inheritedXOffset?: number; // X offset inherited from parent parallel flow - lastSubflow?: SubflowContext; // Last subflow context for this subflow for nested flows -} - -export interface ParallelFlowContext { - subflowFunctionCallIds: string[]; - completedSubflows: Set; - startX: number; - startY: number; - currentXOffset: number; - maxHeight: number; -} - -export interface AgentNodeInfo { - id: string; - name: string; - type: "orchestrator" | "peer"; - phaseId?: string; - subflowId?: string; - context: "main" | "subflow"; - nodeInstance: NodeInstance; -} - -export interface AgentRegistry { - agents: Map; - findAgentByName(name: string): AgentNodeInfo | null; - findAgentById(id: string): AgentNodeInfo | null; - registerAgent(info: AgentNodeInfo): void; -} - -export interface TimelineLayoutManager { - phases: PhaseContext[]; - currentPhaseIndex: number; - currentSubflowIndex: number; // -1 if not in a subflow - parallelFlows: Map; // Key: an ID for the parallel block - - nextAvailableGlobalY: number; // Tracks the Y for the next major top-level element - - nodeIdCounter: number; // For generating unique IDs - allCreatedNodeIds: Set; // For the addNode helper - nodePositions: Map; // For quick lookup if needed by createEdge - - // Global UserNode tracking for new logic - allUserNodes: NodeInstance[]; // Global tracking of all user nodes - userNodeCounter: number; // For unique user node IDs - - // Agent registry for peer-to-peer delegation - agentRegistry: AgentRegistry; - - // Indentation tracking for agent delegation visualization - indentationLevel: number; // Current indentation level - indentationStep: number; // Pixels to indent per level - - // Agent name to display name mapping - agentNameMap?: Record; -} - -// Layout Constants -export const LANE_X_POSITIONS = { - USER: 50, - MAIN_FLOW: 300, // Orchestrator, PeerAgent - TOOLS: 600, -}; - -export const Y_START = 50; -export const NODE_HEIGHT = 50; // Approximate height for calculations -export const NODE_WIDTH = 330; // Approximate width for nodes (increased from 250) -export const VERTICAL_SPACING = 50; // Space between distinct phases or elements -export const GROUP_PADDING_Y = 20; // Vertical padding inside a group box -export const GROUP_PADDING_X = 10; // Horizontal padding inside a group box -export const TOOL_STACKING_OFFSET = NODE_HEIGHT + 20; // Vertical offset for stacked tools under the same agent -export const USER_NODE_Y_OFFSET = 90; // Offset to position UserNode slightly lower - -// Helper to add a node and corresponding action -export function addNode(nodes: Node[], createdNodeIds: Set, nodePayload: Node): Node { - nodes.push(nodePayload); - createdNodeIds.add(nodePayload.id); - return nodePayload; -} - -// Helper to add an edge and corresponding action -export function addEdgeAction(edges: Edge[], edgePayload: Edge): Edge { - edges.push(edgePayload); - return edgePayload; -} - -// Utility Functions -export function generateNodeId(context: TimelineLayoutManager, prefix: string): string { - context.nodeIdCounter++; - return `${prefix.replace(/[^a-zA-Z0-9_]/g, "_")}_${context.nodeIdCounter}`; -} - -export function getCurrentPhase(context: TimelineLayoutManager): PhaseContext | null { - if (context.currentPhaseIndex === -1 || context.currentPhaseIndex >= context.phases.length) { - return null; - } - return context.phases[context.currentPhaseIndex]; -} - -export function getCurrentSubflow(context: TimelineLayoutManager): SubflowContext | null { - const phase = getCurrentPhase(context); - if (!phase || context.currentSubflowIndex === -1 || context.currentSubflowIndex >= phase.subflows.length) { - return null; - } - return phase.subflows[context.currentSubflowIndex]; -} - -// Helper function to find tool instance by name in array -export function findToolInstanceByName(toolInstances: NodeInstance[], toolName: string, nodes: Node[]): NodeInstance | null { - // Find the most recent tool instance with matching name - for (let i = toolInstances.length - 1; i >= 0; i--) { - const toolInstance = toolInstances[i]; - const toolNode = nodes.find(n => n.id === toolInstance.id); - if (toolNode?.data?.toolName === toolName) { - return toolInstance; - } - } - return null; -} - -export function findSubflowByFunctionCallId(context: TimelineLayoutManager, functionCallId: string | undefined): SubflowContext | null { - if (!functionCallId) return null; - const phase = getCurrentPhase(context); - if (!phase) return null; - - return phase.subflows.findLast(sf => sf.functionCallId === functionCallId) || null; -} - -export function findSubflowBySubTaskId(context: TimelineLayoutManager, subTaskId: string | undefined): SubflowContext | null { - if (!subTaskId) return null; - const phase = getCurrentPhase(context); - if (!phase) return null; - return phase.subflows.findLast(sf => sf.id === subTaskId) || null; -} - -// Enhanced context resolution with multiple fallback strategies -export function resolveSubflowContext(manager: TimelineLayoutManager, step: VisualizerStep): SubflowContext | null { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return null; - - // 1. The most reliable match is the functionCallId that initiated the subflow. - if (step.functionCallId) { - const directMatch = findSubflowByFunctionCallId(manager, step.functionCallId); - if (directMatch) { - return directMatch; - } - } - - // 2. The next best match is the sub-task's own task ID. - if (step.owningTaskId && step.isSubTaskStep) { - const taskMatch = findSubflowBySubTaskId(manager, step.owningTaskId); - if (taskMatch) { - // This check is a safeguard against race conditions where two subflows might get the same ID, which shouldn't happen. - const subflows = currentPhase.subflows || []; - const subflowIdHasDuplicate = new Set(subflows.map(sf => sf.id)).size !== subflows.length; - if (!subflowIdHasDuplicate) { - return taskMatch; - } - } - } - - // 3. As a fallback, check if the event source matches the agent of the "current" subflow. - // This is less reliable in parallel scenarios but can help with event ordering issues. - const currentSubflow = getCurrentSubflow(manager); - if (currentSubflow && step.source) { - const normalizedStepSource = step.source.replace(/[^a-zA-Z0-9_]/g, "_"); - const normalizedStepTarget = step.target?.replace(/[^a-zA-Z0-9_]/g, "_"); - const peerAgentId = currentSubflow.peerAgent.id; - if (peerAgentId.includes(normalizedStepSource) || (normalizedStepTarget && peerAgentId.includes(normalizedStepTarget))) { - return currentSubflow; - } - } - - // 4. Final fallback to the current subflow context. - if (currentSubflow) { - return currentSubflow; - } - - return null; -} - -// Enhanced subflow finder by sub-task ID with better matching -export function findSubflowBySubTaskIdEnhanced(manager: TimelineLayoutManager, subTaskId: string): SubflowContext | null { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return null; - - // Find all matching subflows, using direct or partial match - const matchingSubflows = currentPhase.subflows.filter(sf => sf.id === subTaskId || sf.id.includes(subTaskId) || subTaskId.includes(sf.id)); - - if (matchingSubflows.length === 0) { - return null; - } - - // Return the last one in the array, as it's the most recent instance. - return matchingSubflows[matchingSubflows.length - 1]; -} - -// Determine if this is truly a parallel flow -export function isParallelFlow(step: VisualizerStep, manager: TimelineLayoutManager): boolean { - // Case 1: The decision step itself. This is where a parallel flow is defined. - if (step.data.toolDecision?.isParallel === true) { - return true; - } - - // Case 2: The invocation step. This is where a branch of a parallel flow is executed. - // We must check the specific functionCallId of the invocation, not the parent task's ID, - // which is what `step.functionCallId` often contains in nested scenarios. - const invocationFunctionCallId = step.data?.toolInvocationStart?.functionCallId; - - if (invocationFunctionCallId) { - // Check if this specific invocation is part of any registered parallel flow. - return Array.from(manager.parallelFlows.values()).some(p => p.subflowFunctionCallIds.includes(invocationFunctionCallId)); - } - - // If the step is not a parallel decision or a tool invocation with a specific ID, - // it's not considered part of a parallel flow by this logic. - return false; -} - -export function findToolInstanceByNameEnhanced(toolInstances: NodeInstance[], toolName: string, nodes: Node[], functionCallId?: string): NodeInstance | null { - // First try to match by function call ID if provided - if (functionCallId) { - for (let i = toolInstances.length - 1; i >= 0; i--) { - const toolInstance = toolInstances[i]; - if (toolInstance.functionCallId === functionCallId) { - const toolNode = nodes.find(n => n.id === toolInstance.id); - if (toolNode?.data?.toolName === toolName || toolName === "LLM") { - return toolInstance; - } - } - } - } - - return findToolInstanceByName(toolInstances, toolName, nodes); -} - -// Helper function to find parent parallel subflow context (recursive) -export function findParentParallelSubflow(manager: TimelineLayoutManager, sourceAgentName: string, targetAgentName: string): SubflowContext | null { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return null; - - const normalizedSourceAgentName = sourceAgentName.replace(/[^a-zA-Z0-9_]/g, "_"); - const normalizedTargeAgentName = targetAgentName?.replace(/[^a-zA-Z0-9_]/g, "_"); - - let matchedSubflow: SubflowContext | null = null; - for (const subflow of currentPhase.subflows) { - if (subflow.peerAgent.id.includes(normalizedSourceAgentName) || (normalizedTargeAgentName && subflow.peerAgent.id.includes(normalizedTargeAgentName))) { - matchedSubflow = subflow; - break; - } - } - - if (!matchedSubflow) return null; - - // If the source subflow is parallel, return it - if (matchedSubflow.isParallel) { - return matchedSubflow; - } - - // If the source subflow is not parallel but has a parent, recursively look up - if (matchedSubflow.parentSubflowId) { - const parentSubflow = currentPhase.subflows.find(sf => sf.id === matchedSubflow.parentSubflowId); - if (parentSubflow && parentSubflow.isParallel) { - return parentSubflow; - } - // Recursively check parent's parent (for deeper nesting) - if (parentSubflow) { - return findParentParallelSubflowRecursive(currentPhase, parentSubflow); - } - } - - return null; -} - -// Helper function for recursive parent lookup -function findParentParallelSubflowRecursive(currentPhase: PhaseContext, subflow: SubflowContext): SubflowContext | null { - if (subflow.isParallel) { - return subflow; - } - - if (subflow.parentSubflowId) { - const parentSubflow = currentPhase.subflows.find(sf => sf.id === subflow.parentSubflowId); - if (parentSubflow) { - return findParentParallelSubflowRecursive(currentPhase, parentSubflow); - } - } - - return null; -} - -export function createNewMainPhase(manager: TimelineLayoutManager, agentName: string, step: VisualizerStep, nodes: Node[]): PhaseContext { - const phaseId = `phase_${manager.phases.length}`; - const orchestratorNodeId = generateNodeId(manager, `${agentName}_${phaseId}`); - const yPos = manager.nextAvailableGlobalY; - - // Use display name for the node label, fall back to agentName if not found - const displayName = resolveAgentDisplayName(agentName, manager.agentNameMap); - - const orchestratorNode: Node = { - id: orchestratorNodeId, - type: "orchestratorNode", - position: { x: LANE_X_POSITIONS.MAIN_FLOW, y: yPos }, - data: { label: displayName, visualizerStepId: step.id }, - }; - addNode(nodes, manager.allCreatedNodeIds, orchestratorNode); - manager.nodePositions.set(orchestratorNodeId, orchestratorNode.position); - - const orchestratorInstance: NodeInstance = { id: orchestratorNodeId, yPosition: yPos, height: NODE_HEIGHT, width: NODE_WIDTH }; - - // Register the orchestrator agent in the registry - const agentInfo: AgentNodeInfo = { - id: orchestratorNodeId, - name: agentName, - type: "orchestrator", - phaseId: phaseId, - context: "main", - nodeInstance: orchestratorInstance, - }; - manager.agentRegistry.registerAgent(agentInfo); - - const newPhase: PhaseContext = { - id: phaseId, - orchestratorAgent: orchestratorInstance, - userNodes: [], - subflows: [], - toolInstances: [], - currentToolYOffset: 0, - maxY: yPos + NODE_HEIGHT, - }; - manager.phases.push(newPhase); - manager.currentPhaseIndex = manager.phases.length - 1; - manager.currentSubflowIndex = -1; // Ensure we are not in a subflow context - manager.nextAvailableGlobalY = newPhase.maxY + VERTICAL_SPACING; // Prepare Y for next element - - return newPhase; -} - -export function startNewSubflow(manager: TimelineLayoutManager, peerAgentName: string, step: VisualizerStep, nodes: Node[], isParallel: boolean): SubflowContext | null { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return null; - - const isPeerReturn = step.type === "AGENT_TOOL_EXECUTION_RESULT" && step.data.toolResult?.isPeerResponse === true; - - const sourceAgentName = step.source || ""; - const targetAgentName = step.target || ""; - const isFromOrchestrator = isOrchestratorAgent(sourceAgentName); - - if (!isPeerReturn && !isFromOrchestrator && !isParallel) { - manager.indentationLevel++; - } - - const subflowId = step.delegationInfo?.[0]?.subTaskId || step.owningTaskId; - const peerAgentNodeId = generateNodeId(manager, `${peerAgentName}_${subflowId}`); - const groupNodeId = generateNodeId(manager, `group_${peerAgentName}_${subflowId}`); - - const invocationFunctionCallId = step.data?.toolInvocationStart?.functionCallId || step.functionCallId || ""; - - let groupNodeX: number; - let groupNodeY: number; - let peerAgentY: number; - - const parallelFlow = Array.from(manager.parallelFlows.values()).find(p => p.subflowFunctionCallIds.includes(invocationFunctionCallId)); - - // Find the current subflow context (the immediate parent of this new subflow) - const currentSubflow = getCurrentSubflow(manager); - - // Check for parent parallel context - const parentParallelSubflow = findParentParallelSubflow(manager, sourceAgentName, targetAgentName); - - if (isParallel && parallelFlow) { - // Standard parallel flow positioning - groupNodeX = parallelFlow.startX + parallelFlow.currentXOffset; - groupNodeY = parallelFlow.startY; - peerAgentY = groupNodeY + GROUP_PADDING_Y; - parallelFlow.currentXOffset += (NODE_WIDTH + GROUP_PADDING_X) * 2.2; - } else if (parentParallelSubflow) { - // Nested flow within parallel context - inherit parent's X offset - peerAgentY = manager.nextAvailableGlobalY; - const baseX = parentParallelSubflow.groupNode.xPosition || LANE_X_POSITIONS.MAIN_FLOW - 50; - groupNodeX = baseX + manager.indentationLevel * manager.indentationStep; - groupNodeY = peerAgentY - GROUP_PADDING_Y; - } else { - // Standard sequential flow positioning - peerAgentY = manager.nextAvailableGlobalY; - const baseX = LANE_X_POSITIONS.MAIN_FLOW - 50; - groupNodeX = baseX + manager.indentationLevel * manager.indentationStep; - groupNodeY = peerAgentY - GROUP_PADDING_Y; - } - - // Use display name for the peer agent node label, fall back to peerAgentName if not found - const displayName = resolveAgentDisplayName(peerAgentName, manager.agentNameMap); - - const peerAgentNode: Node = { - id: peerAgentNodeId, - type: "genericAgentNode", - position: { - x: 50, - y: GROUP_PADDING_Y, - }, - data: { label: displayName, visualizerStepId: step.id }, - parentId: groupNodeId, - }; - - const groupNode: Node = { - id: groupNodeId, - type: "group", - position: { x: groupNodeX, y: groupNodeY }, - data: { label: `${displayName} Sub-flow` }, - style: { - backgroundColor: "rgba(220, 220, 255, 0.1)", - border: "1px solid #aac", - borderRadius: "8px", - minHeight: `${NODE_HEIGHT + 2 * GROUP_PADDING_Y}px`, - }, - }; - addNode(nodes, manager.allCreatedNodeIds, groupNode); - addNode(nodes, manager.allCreatedNodeIds, peerAgentNode); - manager.nodePositions.set(peerAgentNodeId, peerAgentNode.position); - manager.nodePositions.set(groupNodeId, groupNode.position); - - const peerAgentInstance: NodeInstance = { id: peerAgentNodeId, xPosition: LANE_X_POSITIONS.MAIN_FLOW, yPosition: peerAgentY, height: NODE_HEIGHT, width: NODE_WIDTH }; - - const agentInfo: AgentNodeInfo = { - id: peerAgentNodeId, - name: peerAgentName, - type: "peer", - phaseId: currentPhase.id, - subflowId: subflowId, - context: "subflow", - nodeInstance: peerAgentInstance, - }; - manager.agentRegistry.registerAgent(agentInfo); - - const newSubflow: SubflowContext = { - id: subflowId, - functionCallId: invocationFunctionCallId, - isParallel: isParallel, - peerAgent: peerAgentInstance, - groupNode: { id: groupNodeId, xPosition: groupNodeX, yPosition: groupNodeY, height: NODE_HEIGHT + 2 * GROUP_PADDING_Y, width: 0 }, - toolInstances: [], - currentToolYOffset: 0, - maxY: peerAgentY + NODE_HEIGHT, - maxContentXRelative: peerAgentNode.position.x + NODE_WIDTH, - callingPhaseId: currentPhase.id, - // Add parent context tracking - parentSubflowId: currentSubflow?.id, - inheritedXOffset: parentParallelSubflow?.groupNode.xPosition, - }; - currentPhase.subflows.push(newSubflow); - if (parentParallelSubflow) { - parentParallelSubflow.lastSubflow = newSubflow; // Track last subflow for nested flows - } - manager.currentSubflowIndex = currentPhase.subflows.length - 1; - - if (isParallel && parallelFlow) { - parallelFlow.maxHeight = Math.max(parallelFlow.maxHeight, newSubflow.groupNode.height); - manager.nextAvailableGlobalY = parallelFlow.startY + parallelFlow.maxHeight + VERTICAL_SPACING; - } else { - manager.nextAvailableGlobalY = newSubflow.groupNode.yPosition + newSubflow.groupNode.height + VERTICAL_SPACING; - } - return newSubflow; -} - -export function createNewToolNodeInContext( - manager: TimelineLayoutManager, - toolName: string, - toolType: string, // e.g., 'llmNode', 'genericAgentNode' for tools - step: VisualizerStep, - nodes: Node[], - subflow: SubflowContext | null, - isLLM: boolean = false -): NodeInstance | null { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return null; - - const contextToolArray = subflow ? subflow.toolInstances : currentPhase.toolInstances; - const baseLabel = isLLM ? "LLM" : `Tool: ${toolName}`; - const baseNodeIdPrefix = isLLM ? "LLM" : toolName; - - // Always create new tool instance instead of reusing - const parentGroupId = subflow ? subflow.groupNode.id : undefined; - - // Calculate tool's absolute Y position - let toolY_absolute: number; - let toolX_absolute: number; - - // Position for the node to be created (can be relative or absolute) - let nodePositionX: number; - let nodePositionY: number; - - if (subflow) { - // Tool's Y is relative to the peer agent's absolute Y, plus current offset within the subflow - toolY_absolute = subflow.peerAgent.yPosition + subflow.currentToolYOffset; - subflow.currentToolYOffset += TOOL_STACKING_OFFSET; - - // Position tools with a consistent offset from the peer agent node - // This ensures tools are properly positioned regardless of group indentation - const peerAgentRelativeX = 50; // The peer agent's x position relative to group - const toolOffsetFromPeer = 300; // Desired x-distance from peer agent to tool - - // Position the tool relative to the peer agent - nodePositionX = peerAgentRelativeX + toolOffsetFromPeer; - - // For nodes inside a group, position must be relative to the group's origin - // groupNode.xPosition and yPosition are absolute - if (subflow.groupNode.xPosition === undefined || subflow.groupNode.yPosition === undefined) { - return null; - } - - // Set absolute position for tracking - toolX_absolute = subflow.groupNode.xPosition + nodePositionX; - - // Y position relative to group - nodePositionY = toolY_absolute - subflow.groupNode.yPosition; - } else { - // For tools in the main flow (not in a subflow) - toolX_absolute = LANE_X_POSITIONS.TOOLS; // Default absolute X for main flow tools - - // Tool's Y is relative to the orchestrator agent's absolute Y, plus current offset - toolY_absolute = currentPhase.orchestratorAgent.yPosition + currentPhase.currentToolYOffset; - currentPhase.currentToolYOffset += TOOL_STACKING_OFFSET; - nodePositionX = toolX_absolute; - nodePositionY = toolY_absolute; - } - - // Generate unique ID for each tool call using step ID - const toolNodeId = generateNodeId(manager, `${baseNodeIdPrefix}_${step.id}`); - const toolNode: Node = { - id: toolNodeId, - type: toolType, - position: { x: nodePositionX, y: nodePositionY }, - data: { - label: baseLabel, - visualizerStepId: step.id, - toolName: toolName, - }, - parentId: parentGroupId, - }; - addNode(nodes, manager.allCreatedNodeIds, toolNode); - manager.nodePositions.set(toolNodeId, { x: nodePositionX, y: nodePositionY }); - - // The toolInstance should store the *absolute* position for logical tracking - const toolInstance: NodeInstance = { - id: toolNodeId, - xPosition: toolX_absolute, - yPosition: toolY_absolute, - height: NODE_HEIGHT, - width: NODE_WIDTH, - functionCallId: step.functionCallId, - }; - - // Add to array instead of map - contextToolArray.push(toolInstance); - - // Update maxY for the current context (phase or subflow) using absolute Y - const newMaxYInContext = toolY_absolute + NODE_HEIGHT; // Use absolute Y for maxY tracking - if (subflow) { - subflow.maxY = Math.max(subflow.maxY, newMaxYInContext); // subflow.maxY is absolute - - // Update maxContentXRelative to ensure it accounts for the tool node width - subflow.maxContentXRelative = Math.max(subflow.maxContentXRelative, nodePositionX + NODE_WIDTH); - - // Update group height and nextAvailableGlobalY - // groupNode.yPosition is absolute. maxY is absolute. - const requiredGroupHeight = subflow.maxY - subflow.groupNode.yPosition + GROUP_PADDING_Y; - subflow.groupNode.height = Math.max(subflow.groupNode.height, requiredGroupHeight); - - // Update group width to accommodate the tool nodes - const requiredGroupWidth = subflow.maxContentXRelative + GROUP_PADDING_X; - subflow.groupNode.width = Math.max(subflow.groupNode.width || 0, requiredGroupWidth); - - const groupNodeData = nodes.find(n => n.id === subflow.groupNode.id); - if (groupNodeData) { - groupNodeData.style = { - ...groupNodeData.style, - height: `${subflow.groupNode.height}px`, - width: `${subflow.groupNode.width}px`, - }; - } - - manager.nextAvailableGlobalY = Math.max(manager.nextAvailableGlobalY, subflow.groupNode.yPosition + subflow.groupNode.height + VERTICAL_SPACING); - } else { - currentPhase.maxY = Math.max(currentPhase.maxY, newMaxYInContext); - manager.nextAvailableGlobalY = Math.max(manager.nextAvailableGlobalY, currentPhase.maxY + VERTICAL_SPACING); - } - - return toolInstance; -} - -export function createNewUserNodeAtBottom(manager: TimelineLayoutManager, currentPhase: PhaseContext, step: VisualizerStep, nodes: Node[]): NodeInstance { - manager.userNodeCounter++; - const userNodeId = generateNodeId(manager, `User_response_${manager.userNodeCounter}`); - - // Position at the bottom of the chart - const userNodeY = manager.nextAvailableGlobalY + 20; - - const userNode: Node = { - id: userNodeId, - type: "userNode", - position: { x: LANE_X_POSITIONS.USER, y: userNodeY }, - data: { label: "User", visualizerStepId: step.id, isBottomNode: true }, - }; - - addNode(nodes, manager.allCreatedNodeIds, userNode); - manager.nodePositions.set(userNodeId, userNode.position); - - const userNodeInstance: NodeInstance = { - id: userNodeId, - yPosition: userNodeY, - height: NODE_HEIGHT, - width: NODE_WIDTH, - }; - - // Add to both phase and global tracking - currentPhase.userNodes.push(userNodeInstance); - manager.allUserNodes.push(userNodeInstance); - - // Update layout tracking - const newMaxY = userNodeY + NODE_HEIGHT; - currentPhase.maxY = Math.max(currentPhase.maxY, newMaxY); - - return userNodeInstance; -} - -export function createTimelineEdge( - sourceNodeId: string, - targetNodeId: string, - step: VisualizerStep, - edges: Edge[], - manager: TimelineLayoutManager, - edgeAnimationService: EdgeAnimationService, - _processedSteps: VisualizerStep[], - sourceHandleId?: string, - targetHandleId?: string -): void { - if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) { - return; - } - - // Validate that source and target nodes exist - const sourceExists = manager.allCreatedNodeIds.has(sourceNodeId); - const targetExists = manager.allCreatedNodeIds.has(targetNodeId); - - if (!sourceExists) { - return; - } - - if (!targetExists) { - return; - } - - const edgeId = `edge-${sourceNodeId}${sourceHandleId || ""}-to-${targetNodeId}${targetHandleId || ""}-${step.id}`; - - const edgeExists = edges.some(e => e.id === edgeId); - - if (!edgeExists) { - const label = step.title && step.title.length > 30 ? step.type.replace(/_/g, " ").toLowerCase() : step.title || ""; - - // For initial edge creation, assume all agent-to-tool requests start animated - const isAgentToToolRequest = edgeAnimationService.isRequestStep(step); - - const newEdge: Edge = { - id: edgeId, - source: sourceNodeId, - target: targetNodeId, - label: label, - type: "defaultFlowEdge", // Ensure this custom edge type is registered - data: { - visualizerStepId: step.id, - isAnimated: isAgentToToolRequest, // Start animated if it's an agent-to-tool request - animationType: isAgentToToolRequest ? "request" : "static", - duration: 1.0, - } as unknown as Record, - }; - - // Only add handles if they are provided and valid - if (sourceHandleId) { - newEdge.sourceHandle = sourceHandleId; - } - if (targetHandleId) { - newEdge.targetHandle = targetHandleId; - } - - addEdgeAction(edges, newEdge); - } -} - -// Agent Registry Implementation -export function createAgentRegistry(): AgentRegistry { - const agents = new Map(); - - return { - agents, - findAgentByName(name: string): AgentNodeInfo | null { - // Normalize the name to handle variations like "peer_hirerarchy2" vs "hirerarchy2" - const normalizedName = name.startsWith("peer_") ? name.substring(5) : name; - - // Find all agents with matching name and return the most recent one - const matchingAgents: AgentNodeInfo[] = []; - for (const [, agentInfo] of agents) { - if (agentInfo.name === normalizedName || agentInfo.name === name) { - matchingAgents.push(agentInfo); - } - } - - if (matchingAgents.length === 0) { - return null; - } - - // Return the most recently created agent (highest node ID counter) - // Node IDs are generated with incrementing counter, so higher ID = more recent - return matchingAgents.reduce((latest, current) => { - const latestIdNum = parseInt(latest.id.split("_").pop() || "0"); - const currentIdNum = parseInt(current.id.split("_").pop() || "0"); - return currentIdNum > latestIdNum ? current : latest; - }); - }, - - findAgentById(id: string): AgentNodeInfo | null { - for (const [, agentInfo] of agents) { - if (agentInfo.id === id) { - return agentInfo; - } - } - return null; - }, - - registerAgent(info: AgentNodeInfo): void { - agents.set(info.id, info); - }, - }; -} - -// Helper function to get correct handle IDs based on agent type -export function getAgentHandle(agentType: "orchestrator" | "peer", direction: "input" | "output", position: "top" | "bottom" | "right"): string { - if (agentType === "orchestrator") { - if (direction === "output") { - return position === "bottom" ? "orch-bottom-output" : "orch-right-output-tools"; - } else { - return position === "top" ? "orch-top-input" : "orch-right-input-tools"; - } - } else { - // peer - if (direction === "output") { - return position === "bottom" ? "peer-bottom-output" : "peer-right-output-tools"; - } else { - return position === "top" ? "peer-top-input" : "peer-right-input-tools"; - } - } -} - -// Helper function to determine if an agent name represents an orchestrator -export function isOrchestratorAgent(agentName: string): boolean { - return agentName === "OrchestratorAgent" || agentName.toLowerCase().includes("orchestrator"); -} diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/taskToFlowData.ts b/client/webui/frontend/src/lib/components/activities/FlowChart/taskToFlowData.ts deleted file mode 100644 index d106f3fd0..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChart/taskToFlowData.ts +++ /dev/null @@ -1,869 +0,0 @@ -import type { Node, Edge } from "@xyflow/react"; - -import type { VisualizerStep } from "@/lib/types"; - -import { - addNode, - type TimelineLayoutManager, - type NodeInstance, - LANE_X_POSITIONS, - Y_START, - NODE_HEIGHT, - NODE_WIDTH, - VERTICAL_SPACING, - GROUP_PADDING_Y, - GROUP_PADDING_X, - USER_NODE_Y_OFFSET, - generateNodeId, - getCurrentPhase, - getCurrentSubflow, - resolveSubflowContext, - isParallelFlow, - findToolInstanceByNameEnhanced, - createNewMainPhase, - startNewSubflow, - createNewToolNodeInContext, - createTimelineEdge, - createNewUserNodeAtBottom, - createAgentRegistry, - getAgentHandle, - isOrchestratorAgent, -} from "./taskToFlowData.helpers"; -import { EdgeAnimationService } from "./edgeAnimationService"; - -// Relevant step types that should be processed in the flow chart -const RELEVANT_STEP_TYPES = [ - "USER_REQUEST", - "AGENT_LLM_CALL", - "AGENT_LLM_RESPONSE_TO_AGENT", - "AGENT_LLM_RESPONSE_TOOL_DECISION", - "AGENT_TOOL_INVOCATION_START", - "AGENT_TOOL_EXECUTION_RESULT", - "AGENT_RESPONSE_TEXT", - "AGENT_ARTIFACT_NOTIFICATION", - "TASK_COMPLETED", - "TASK_FAILED", -]; - -interface FlowData { - nodes: Node[]; - edges: Edge[]; -} - -export interface AnimatedEdgeData { - visualizerStepId: string; - isAnimated?: boolean; - animationType?: "request" | "response" | "static"; -} - -function handleUserRequest(step: VisualizerStep, manager: TimelineLayoutManager, nodes: Node[], edges: Edge[], edgeAnimationService: EdgeAnimationService, processedSteps: VisualizerStep[]): void { - const targetAgentName = step.target as string; - const sanitizedTargetAgentName = targetAgentName.replace(/[^a-zA-Z0-9_]/g, "_"); - - const currentPhase = getCurrentPhase(manager); - const currentSubflow = getCurrentSubflow(manager); - - let lastAgentNode: NodeInstance | undefined; - let connectToLastAgent = false; - - if (currentSubflow) { - lastAgentNode = currentSubflow.peerAgent; - if (lastAgentNode.id.startsWith(sanitizedTargetAgentName + "_")) { - connectToLastAgent = true; - } - } else if (currentPhase) { - lastAgentNode = currentPhase.orchestratorAgent; - if (lastAgentNode.id.startsWith(sanitizedTargetAgentName + "_")) { - connectToLastAgent = true; - } - } - - if (connectToLastAgent && lastAgentNode && currentPhase) { - // Continued conversation: Create a "middle" user node and connect it to the last agent. - manager.userNodeCounter++; - const userNodeId = generateNodeId(manager, `User_continue_${manager.userNodeCounter}`); - - // Position the new user node at the current bottom of the flow. - const userNodeY = manager.nextAvailableGlobalY; - - const userNode: Node = { - id: userNodeId, - type: "userNode", - position: { x: LANE_X_POSITIONS.USER, y: userNodeY }, - // No isTopNode or isBottomNode, so it will be a "middle" node with a right handle. - data: { label: "User", visualizerStepId: step.id }, - }; - - addNode(nodes, manager.allCreatedNodeIds, userNode); - manager.nodePositions.set(userNodeId, userNode.position); - - const userNodeInstance: NodeInstance = { - id: userNodeId, - yPosition: userNodeY, - height: NODE_HEIGHT, - width: NODE_WIDTH, - }; - - // Add to tracking - currentPhase.userNodes.push(userNodeInstance); - manager.allUserNodes.push(userNodeInstance); - - // Update layout tracking to position subsequent nodes correctly. - const newMaxY = userNodeY + NODE_HEIGHT; - // An agent will be created at the same Y level, so we take the max. - lastAgentNode.yPosition = Math.max(lastAgentNode.yPosition, userNodeY); - currentPhase.maxY = Math.max(currentPhase.maxY, newMaxY, lastAgentNode.yPosition + NODE_HEIGHT); - manager.nextAvailableGlobalY = currentPhase.maxY + VERTICAL_SPACING; - - // The agent receiving the request is the target. - const targetAgentHandle = isOrchestratorAgent(targetAgentName) ? "orch-left-input" : "peer-left-input"; - - createTimelineEdge( - userNodeInstance.id, - lastAgentNode.id, - step, - edges, - manager, - edgeAnimationService, - processedSteps, - "user-right-output", // Source from the new right handle - targetAgentHandle // Target the top of the agent - ); - } else { - // Original behavior: create a new phase for the user request. - const phase = createNewMainPhase(manager, targetAgentName, step, nodes); - - const userNodeId = generateNodeId(manager, `User_${phase.id}`); - const userNode: Node = { - id: userNodeId, - type: "userNode", - position: { x: LANE_X_POSITIONS.USER, y: phase.orchestratorAgent.yPosition - USER_NODE_Y_OFFSET }, - data: { label: "User", visualizerStepId: step.id, isTopNode: true }, - }; - addNode(nodes, manager.allCreatedNodeIds, userNode); - manager.nodePositions.set(userNodeId, userNode.position); - - const userNodeInstance = { id: userNodeId, yPosition: userNode.position.y, height: NODE_HEIGHT, width: NODE_WIDTH }; - phase.userNodes.push(userNodeInstance); // Add to userNodes array - manager.allUserNodes.push(userNodeInstance); // Add to global tracking - manager.userNodeCounter++; - - phase.maxY = Math.max(phase.maxY, userNode.position.y + NODE_HEIGHT); - manager.nextAvailableGlobalY = phase.maxY + VERTICAL_SPACING; - - createTimelineEdge( - userNodeId, - phase.orchestratorAgent.id, - step, - edges, - manager, - edgeAnimationService, - processedSteps, - "user-bottom-output", // UserNode output - "orch-top-input" // OrchestratorAgent input from user - ); - } -} - -function handleLLMCall(step: VisualizerStep, manager: TimelineLayoutManager, nodes: Node[], edges: Edge[], edgeAnimationService: EdgeAnimationService, processedSteps: VisualizerStep[]): void { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return; - - // Use enhanced context resolution - const subflow = resolveSubflowContext(manager, step); - - const sourceAgentNodeId = subflow ? subflow.peerAgent.id : currentPhase.orchestratorAgent.id; - const llmToolInstance = createNewToolNodeInContext(manager, "LLM", "llmNode", step, nodes, subflow, true); - - if (llmToolInstance) { - createTimelineEdge( - sourceAgentNodeId, - llmToolInstance.id, - step, - edges, - manager, - edgeAnimationService, - processedSteps, - subflow ? "peer-right-output-tools" : "orch-right-output-tools", // Agent output to LLM - "llm-left-input" // LLM input - ); - } -} - -function handleLLMResponseToAgent(step: VisualizerStep, manager: TimelineLayoutManager, nodes: Node[], edges: Edge[], edgeAnimationService: EdgeAnimationService, processedSteps: VisualizerStep[]): void { - // If this is a parallel tool decision with multiple peer agents delegation, set up the parallel flow context - if (step.type === "AGENT_LLM_RESPONSE_TOOL_DECISION" && step.data.toolDecision?.isParallel) { - const parallelFlowId = `parallel-${step.id}`; - if (step.data.toolDecision.decisions.filter(d => d.isPeerDelegation).length > 1) { - manager.parallelFlows.set(parallelFlowId, { - subflowFunctionCallIds: step.data.toolDecision.decisions.filter(d => d.isPeerDelegation).map(d => d.functionCallId), - completedSubflows: new Set(), - startX: LANE_X_POSITIONS.MAIN_FLOW - 50, - startY: manager.nextAvailableGlobalY, - currentXOffset: 0, - maxHeight: 0, - }); - } - } - - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return; - - // Use enhanced context resolution - const subflow = resolveSubflowContext(manager, step); - - let llmNodeId: string | undefined; - // LLM node should exist from a previous AGENT_LLM_CALL - // Find the most recent LLM instance within the correct context - const context = subflow || currentPhase; - - const llmInstance = findToolInstanceByNameEnhanced(context.toolInstances, "LLM", nodes, step.functionCallId); - - if (llmInstance) { - llmNodeId = llmInstance.id; - } else { - console.error(`[Timeline] LLM node not found for step type ${step.type}: ${step.id}. Cannot create edge.`); - return; - } - - // Target is the agent that received the response - const targetAgentName = step.target || "UnknownAgent"; - let targetAgentNodeId: string | undefined; - let targetAgentHandleId: string | undefined; - - if (subflow) { - targetAgentNodeId = subflow.peerAgent.id; - targetAgentHandleId = "peer-right-input-tools"; - } else if (currentPhase.orchestratorAgent.id.startsWith(targetAgentName.replace(/[^a-zA-Z0-9_]/g, "_") + "_")) { - targetAgentNodeId = currentPhase.orchestratorAgent.id; - targetAgentHandleId = "orch-right-input-tools"; - } - - if (llmNodeId && targetAgentNodeId && targetAgentHandleId) { - createTimelineEdge( - llmNodeId, - targetAgentNodeId, - step, - edges, - manager, - edgeAnimationService, - processedSteps, - "llm-bottom-output", // LLM's bottom output handle - targetAgentHandleId // Agent's right input handle - ); - } else { - console.error(`[Timeline] Could not determine target agent node ID or handle for step type ${step.type}: ${step.id}. Target agent name: ${targetAgentName}. Edge will be missing.`); - } -} - -function handleToolInvocationStart(step: VisualizerStep, manager: TimelineLayoutManager, nodes: Node[], edges: Edge[], edgeAnimationService: EdgeAnimationService, processedSteps: VisualizerStep[]): void { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return; - - const sourceName = step.source || "UnknownSource"; - const targetToolName = step.target || "UnknownTool"; - - const isPeerDelegation = step.data.toolInvocationStart?.isPeerInvocation || targetToolName.startsWith("peer_"); - - if (isPeerDelegation) { - const peerAgentName = targetToolName.startsWith("peer_") ? targetToolName.substring(5) : targetToolName; - - // Instead of relying on the current subflow context, which can be polluted by the - // first parallel node, we find the source agent directly from the registry. - const sourceAgentInfo = manager.agentRegistry.findAgentByName(sourceName); - if (!sourceAgentInfo) { - console.error(`[Timeline] Could not find source agent in registry: ${sourceName} for step ${step.id}`); - return; - } - - const sourceAgent = sourceAgentInfo.nodeInstance; - // All agent-to-agent delegations use the bottom-to-top handles. - const sourceHandle = getAgentHandle(sourceAgentInfo.type, "output", "bottom"); - - const isParallel = isParallelFlow(step, manager); - - const subflowContext = startNewSubflow(manager, peerAgentName, step, nodes, isParallel); - if (subflowContext) { - createTimelineEdge(sourceAgent.id, subflowContext.peerAgent.id, step, edges, manager, edgeAnimationService, processedSteps, sourceHandle, "peer-top-input"); - } - } else { - // Regular tool call - const subflow = resolveSubflowContext(manager, step); - let sourceNodeId: string; - let sourceHandle: string; - - if (subflow) { - sourceNodeId = subflow.peerAgent.id; - sourceHandle = "peer-right-output-tools"; - } else { - const sourceAgent = manager.agentRegistry.findAgentByName(sourceName); - if (sourceAgent) { - sourceNodeId = sourceAgent.id; - sourceHandle = getAgentHandle(sourceAgent.type, "output", "right"); - } else { - sourceNodeId = currentPhase.orchestratorAgent.id; - sourceHandle = "orch-right-output-tools"; - } - } - - const toolInstance = createNewToolNodeInContext(manager, targetToolName, "genericToolNode", step, nodes, subflow); - if (toolInstance) { - createTimelineEdge(sourceNodeId, toolInstance.id, step, edges, manager, edgeAnimationService, processedSteps, sourceHandle, `${toolInstance.id}-tool-left-input`); - } - } -} - -function handleToolExecutionResult(step: VisualizerStep, manager: TimelineLayoutManager, nodes: Node[], edges: Edge[], edgeAnimationService: EdgeAnimationService, processedSteps: VisualizerStep[]): void { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return; - - const stepSource = step.source || "UnknownSource"; - const targetAgentName = step.target || "OrchestratorAgent"; - - if (step.data.toolResult?.isPeerResponse) { - const returningFunctionCallId = step.data.toolResult?.functionCallId; - - // 1. FIRST, check if this return belongs to any active parallel flow. - const parallelFlowEntry = Array.from(manager.parallelFlows.entries()).find( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([_id, pf]) => pf.subflowFunctionCallIds.includes(returningFunctionCallId || "") - ); - - if (parallelFlowEntry) { - // It's a parallel return. Handle the special join logic. - const [parallelFlowId, parallelFlow] = parallelFlowEntry; - - parallelFlow.completedSubflows.add(returningFunctionCallId || ""); - - if (parallelFlow.completedSubflows.size < parallelFlow.subflowFunctionCallIds.length) { - // Not all parallel tasks are done yet. Just record completion and wait. - return; - } - - // 2. ALL parallel tasks are done. Create a SINGLE "join" node. - const sourceSubflows = currentPhase.subflows.filter(sf => parallelFlow.subflowFunctionCallIds.includes(sf.functionCallId)); - - const joinTargetAgentName = step.target || "OrchestratorAgent"; - let joinNode: NodeInstance; - let joinNodeHandle: string; - - if (isOrchestratorAgent(joinTargetAgentName)) { - // The parallel tasks are returning to the main orchestrator. - manager.indentationLevel = 0; - const newOrchestratorPhase = createNewMainPhase(manager, joinTargetAgentName, step, nodes); - joinNode = newOrchestratorPhase.orchestratorAgent; - joinNodeHandle = "orch-top-input"; - manager.currentSubflowIndex = -1; // Return to main flow context - } else { - // The parallel tasks are returning to a PEER agent (nested parallel). - // Create ONE new instance of that peer agent for them to join to. - manager.indentationLevel = Math.max(0, manager.indentationLevel - 1); - const newSubflowForJoin = startNewSubflow(manager, joinTargetAgentName, step, nodes, false); - if (!newSubflowForJoin) return; - joinNode = newSubflowForJoin.peerAgent; - joinNodeHandle = "peer-top-input"; - } - - // 3. Connect ALL completed parallel agents to this single join node. - sourceSubflows.forEach(subflow => { - createTimelineEdge( - subflow.lastSubflow?.peerAgent.id ?? subflow.peerAgent.id, - joinNode.id, - step, // Use the final step as the representative event for the join - edges, - manager, - edgeAnimationService, - processedSteps, - "peer-bottom-output", - joinNodeHandle - ); - }); - - // 4. Clean up the completed parallel flow to prevent reuse. - manager.parallelFlows.delete(parallelFlowId); - - return; // Exit after handling the parallel join. - } - - // If we reach here, it's a NON-PARALLEL (sequential) peer return. - const sourceAgent = manager.agentRegistry.findAgentByName(stepSource.startsWith("peer_") ? stepSource.substring(5) : stepSource); - if (!sourceAgent) { - console.error(`[Timeline] Source peer agent not found for peer response: ${stepSource}.`); - return; - } - - if (isOrchestratorAgent(targetAgentName)) { - manager.indentationLevel = 0; - const newOrchestratorPhase = createNewMainPhase(manager, targetAgentName, step, nodes); - createTimelineEdge(sourceAgent.id, newOrchestratorPhase.orchestratorAgent.id, step, edges, manager, edgeAnimationService, processedSteps, "peer-bottom-output", "orch-top-input"); - manager.currentSubflowIndex = -1; - } else { - // Peer-to-peer sequential return. - manager.indentationLevel = Math.max(0, manager.indentationLevel - 1); - - // Check if we need to consider parallel flow context for this return - const isWithinParallelContext = isParallelFlow(step, manager) || Array.from(manager.parallelFlows.values()).some(pf => pf.subflowFunctionCallIds.some(id => currentPhase.subflows.some(sf => sf.functionCallId === id))); - - const newSubflow = startNewSubflow(manager, targetAgentName, step, nodes, isWithinParallelContext); - if (newSubflow) { - createTimelineEdge(sourceAgent.id, newSubflow.peerAgent.id, step, edges, manager, edgeAnimationService, processedSteps, "peer-bottom-output", "peer-top-input"); - } - } - } else { - // Regular tool (non-peer) returning result - let toolNodeId: string | undefined; - const subflow = resolveSubflowContext(manager, step); - const context = subflow || currentPhase; - const toolInstance = findToolInstanceByNameEnhanced(context.toolInstances, stepSource, nodes, step.functionCallId); - - if (toolInstance) { - toolNodeId = toolInstance.id; - } - - if (toolNodeId) { - let receivingAgentNodeId: string; - let targetHandle: string; - - if (subflow) { - receivingAgentNodeId = subflow.peerAgent.id; - targetHandle = "peer-right-input-tools"; - } else { - const targetAgent = manager.agentRegistry.findAgentByName(targetAgentName); - if (targetAgent) { - receivingAgentNodeId = targetAgent.id; - targetHandle = getAgentHandle(targetAgent.type, "input", "right"); - } else { - receivingAgentNodeId = currentPhase.orchestratorAgent.id; - targetHandle = "orch-right-input-tools"; - } - } - - createTimelineEdge(toolNodeId, receivingAgentNodeId, step, edges, manager, edgeAnimationService, processedSteps, stepSource === "LLM" ? "llm-bottom-output" : `${toolNodeId}-tool-bottom-output`, targetHandle); - } else { - console.error(`[Timeline] Could not find source tool node for regular tool result: ${step.id}. Step source (tool name): ${stepSource}.`); - } - } -} - -function handleArtifactNotification(step: VisualizerStep, manager: TimelineLayoutManager, nodes: Node[], edges: Edge[], edgeAnimationService: EdgeAnimationService, processedSteps: VisualizerStep[]): void { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return; - - const artifactData = step.data.artifactNotification; - const artifactName = artifactData?.artifactName || "Unnamed Artifact"; - - const subflow = resolveSubflowContext(manager, step); - - // Find the tool node that created this artifact - // We look for a tool that was invoked with the same functionCallId or recently executed - const context = subflow || currentPhase; - let sourceToolNode: NodeInstance | undefined; - - if (step.functionCallId) { - sourceToolNode = findToolInstanceByNameEnhanced(context.toolInstances, "", nodes, step.functionCallId) ?? undefined; - } - - if (!sourceToolNode && context.toolInstances.length > 0) { - sourceToolNode = context.toolInstances[context.toolInstances.length - 1]; - } - - let sourceNodeId: string; - let sourceHandle: string; - - if (sourceToolNode) { - sourceNodeId = sourceToolNode.id; - sourceHandle = `${sourceToolNode.id}-tool-right-output-artifact`; - } else return; // Cannot create artifact node without a source tool - - // Create artifact node positioned to the RIGHT of the tool node - const artifactNodeId = generateNodeId(manager, `Artifact_${artifactName}_${step.id}`); - const parentGroupId = subflow ? subflow.groupNode.id : undefined; - - let artifactX: number; - let artifactY: number; - - const ARTIFACT_X_OFFSET = 300; // Horizontal distance from tool to artifact - const ARTIFACT_DIFFERENCE_X = 100; - - if (subflow) { - // For artifacts in a subflow, position relative to the group (like tools do) - const toolNode = nodes.find(n => n.id === sourceToolNode.id); - let relativeToolX: number; - let relativeToolY: number; - - if (toolNode) { - // toolNode.position is already relative to the group - relativeToolX = toolNode.position.x; - relativeToolY = toolNode.position.y; - } else { - // Fallback: calculate relative position from absolute position - const groupX = subflow.groupNode.xPosition ?? LANE_X_POSITIONS.TOOLS; - relativeToolX = (sourceToolNode.xPosition ?? LANE_X_POSITIONS.TOOLS) - groupX; - relativeToolY = (sourceToolNode.yPosition ?? manager.nextAvailableGlobalY) - (subflow.groupNode.yPosition ?? manager.nextAvailableGlobalY); - } - - // Position artifact relative to group, offset from the tool node - artifactX = relativeToolX + ARTIFACT_X_OFFSET; - artifactY = relativeToolY; - } else { - // For main flow, use absolute positioning (like tools do) - artifactX = (sourceToolNode.xPosition ?? LANE_X_POSITIONS.TOOLS) + ARTIFACT_X_OFFSET; - artifactY = sourceToolNode.yPosition ?? manager.nextAvailableGlobalY; - } - - const artifactNode: Node = { - id: artifactNodeId, - type: "artifactNode", - position: { x: artifactX, y: artifactY }, - data: { - label: "Artifact", - visualizerStepId: step.id, - }, - parentId: parentGroupId, - }; - - addNode(nodes, manager.allCreatedNodeIds, artifactNode); - manager.nodePositions.set(artifactNodeId, { x: artifactX, y: artifactY }); - - const artifactInstance: NodeInstance = { - id: artifactNodeId, - xPosition: artifactX, - yPosition: artifactY, - height: NODE_HEIGHT, - width: NODE_WIDTH, - }; - - createTimelineEdge(sourceNodeId, artifactInstance.id, step, edges, manager, edgeAnimationService, processedSteps, sourceHandle, `${artifactInstance.id}-artifact-left-input`); - - // Update maxY and maxContentXRelative to ensure group accommodates the artifact - const artifactBottom = artifactY + NODE_HEIGHT; - const artifactRight = artifactX + NODE_WIDTH; - - if (subflow) { - subflow.maxY = Math.max(subflow.maxY, artifactBottom); - - // Update maxContentXRelative to include artifact node - subflow.maxContentXRelative = Math.max(subflow.maxContentXRelative, artifactRight - ARTIFACT_DIFFERENCE_X); - - // Update group dimensions - const requiredGroupWidth = subflow.maxContentXRelative + GROUP_PADDING_X; - subflow.groupNode.width = Math.max(subflow.groupNode.width || 0, requiredGroupWidth); - - // Update the actual group node in the nodes array - const groupNodeData = nodes.find(n => n.id === subflow.groupNode.id); - if (groupNodeData && groupNodeData.style) { - groupNodeData.style.width = `${subflow.groupNode.width}px`; - } - } - currentPhase.maxY = Math.max(currentPhase.maxY, artifactBottom); -} - -function handleAgentResponseText(step: VisualizerStep, manager: TimelineLayoutManager, nodes: Node[], edges: Edge[], edgeAnimationService: EdgeAnimationService, processedSteps: VisualizerStep[]): void { - const currentPhase = getCurrentPhase(manager); - // When step.isSubTaskStep is true, it indicates this is a response from Agent to Orchestrator (as a user) - if (!currentPhase || step.isSubTaskStep) return; - - const sourceAgentNodeId = currentPhase.orchestratorAgent.id; - - // Always create a new UserNode at the bottom of the chart for each response - const userNodeInstance = createNewUserNodeAtBottom(manager, currentPhase, step, nodes); - - createTimelineEdge( - sourceAgentNodeId, // OrchestratorAgent - userNodeInstance.id, // UserNode - step, - edges, - manager, - edgeAnimationService, - processedSteps, - "orch-bottom-output", // Orchestrator output to user - "user-top-input" // User input from orchestrator - ); -} - -function handleTaskCompleted(step: VisualizerStep, manager: TimelineLayoutManager, nodes: Node[], edges: Edge[], edgeAnimationService: EdgeAnimationService, processedSteps: VisualizerStep[]): void { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return; - - const parallelFlow = Array.from(manager.parallelFlows.values()).find(p => p.subflowFunctionCallIds.includes(step.functionCallId || "")); - - if (parallelFlow) { - parallelFlow.completedSubflows.add(step.functionCallId || ""); - if (parallelFlow.completedSubflows.size === parallelFlow.subflowFunctionCallIds.length) { - // All parallel flows are complete, create a join node - manager.indentationLevel = 0; - const newOrchestratorPhase = createNewMainPhase(manager, "OrchestratorAgent", step, nodes); - - // Connect all completed subflows to the new orchestrator node - currentPhase.subflows.forEach(subflow => { - if (parallelFlow.subflowFunctionCallIds.includes(subflow.functionCallId)) { - createTimelineEdge(subflow.peerAgent.id, newOrchestratorPhase.orchestratorAgent.id, step, edges, manager, edgeAnimationService, processedSteps, "peer-bottom-output", "orch-top-input"); - } - }); - manager.currentSubflowIndex = -1; - } - return; - } - - if (!step.isSubTaskStep) { - return; - } - - const subflow = getCurrentSubflow(manager); - if (!subflow) { - console.warn(`[Timeline] TASK_COMPLETED with isSubTaskStep=true but no active subflow. Step ID: ${step.id}`); - return; - } - - if (!currentPhase) { - console.error(`[Timeline] No current phase found for TASK_COMPLETED. Step ID: ${step.id}`); - return; - } - - const sourcePeerAgent = subflow.peerAgent; - - // Check if an orchestrator node exists anywhere in the flow - const hasOrchestrator = nodes.some(node => typeof node.data.label === "string" && isOrchestratorAgent(node.data.label)); - - let targetNodeId: string; - let targetHandleId: string; - - if (hasOrchestrator) { - // Subtask is completing and returning to the orchestrator. - // Create a new phase for the orchestrator to continue. - manager.indentationLevel = 0; - // We need the orchestrator's name. Let's assume it's 'OrchestratorAgent'. - const newOrchestratorPhase = createNewMainPhase(manager, "OrchestratorAgent", step, nodes); - targetNodeId = newOrchestratorPhase.orchestratorAgent.id; - targetHandleId = "orch-top-input"; - } else { - // No orchestrator found, treat the return as a response to the User. - const userNodeInstance = createNewUserNodeAtBottom(manager, currentPhase, step, nodes); - targetNodeId = userNodeInstance.id; - targetHandleId = "user-top-input"; - } - - createTimelineEdge(sourcePeerAgent.id, targetNodeId, step, edges, manager, edgeAnimationService, processedSteps, "peer-bottom-output", targetHandleId); - - manager.currentSubflowIndex = -1; -} - -function handleTaskFailed(step: VisualizerStep, manager: TimelineLayoutManager, nodes: Node[], edges: Edge[]): void { - const currentPhase = getCurrentPhase(manager); - if (!currentPhase) return; - - const sourceName = step.source || "UnknownSource"; - const targetName = step.target || "User"; - - // Find the last agent node from the agents in current phase that matches the source - let sourceAgentNode: NodeInstance | undefined; - let sourceHandle = "orch-bottom-output"; // Default handle - - // Check if source is in current subflow - const currentSubflow = getCurrentSubflow(manager); - if (currentSubflow && currentSubflow.peerAgent.id.includes(sourceName.replace(/[^a-zA-Z0-9_]/g, "_"))) { - sourceAgentNode = currentSubflow.peerAgent; - sourceHandle = "peer-bottom-output"; - } else { - // Check if source matches orchestrator agent - if (currentPhase.orchestratorAgent.id.includes(sourceName.replace(/[^a-zA-Z0-9_]/g, "_"))) { - sourceAgentNode = currentPhase.orchestratorAgent; - sourceHandle = "orch-bottom-output"; - } else { - // Look for any peer agent in subflows that matches the source - for (const subflow of currentPhase.subflows) { - if (subflow.peerAgent.id.includes(sourceName.replace(/[^a-zA-Z0-9_]/g, "_"))) { - sourceAgentNode = subflow.peerAgent; - sourceHandle = "peer-bottom-output"; - break; - } - } - } - } - - if (!sourceAgentNode) { - console.error(`[Timeline] Could not find source agent node for TASK_FAILED: ${sourceName}`); - return; - } - - // Create a new target node with error state - let targetNodeId: string; - let targetHandleId: string; - - if (isOrchestratorAgent(targetName)) { - // Create a new orchestrator phase for error handling - manager.indentationLevel = 0; - const newOrchestratorPhase = createNewMainPhase(manager, targetName, step, nodes); - - targetNodeId = newOrchestratorPhase.orchestratorAgent.id; - targetHandleId = "orch-top-input"; - manager.currentSubflowIndex = -1; - } else { - // Create a new user node at the bottom for error notification - const userNodeInstance = createNewUserNodeAtBottom(manager, currentPhase, step, nodes); - - targetNodeId = userNodeInstance.id; - targetHandleId = "user-top-input"; - } - - // Create an error edge (red color) between source and target - createErrorEdge(sourceAgentNode.id, targetNodeId, step, edges, manager, sourceHandle, targetHandleId); -} - -// Helper function to create error edges with error state data -function createErrorEdge(sourceNodeId: string, targetNodeId: string, step: VisualizerStep, edges: Edge[], manager: TimelineLayoutManager, sourceHandleId?: string, targetHandleId?: string): void { - if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) { - return; - } - - // Validate that source and target nodes exist - const sourceExists = manager.allCreatedNodeIds.has(sourceNodeId); - const targetExists = manager.allCreatedNodeIds.has(targetNodeId); - - if (!sourceExists || !targetExists) { - return; - } - - const edgeId = `error-edge-${sourceNodeId}${sourceHandleId || ""}-to-${targetNodeId}${targetHandleId || ""}-${step.id}`; - - const edgeExists = edges.some(e => e.id === edgeId); - - if (!edgeExists) { - const errorMessage = step.data.errorDetails?.message || "Task failed"; - const label = errorMessage.length > 30 ? "Error" : errorMessage; - - const newEdge: Edge = { - id: edgeId, - source: sourceNodeId, - target: targetNodeId, - label: label, - type: "defaultFlowEdge", - data: { - visualizerStepId: step.id, - isAnimated: false, - animationType: "static", - isError: true, - errorMessage: errorMessage, - } as unknown as Record, - }; - - // Only add handles if they are provided and valid - if (sourceHandleId) { - newEdge.sourceHandle = sourceHandleId; - } - if (targetHandleId) { - newEdge.targetHandle = targetHandleId; - } - - edges.push(newEdge); - } -} - -// Main transformation function -export const transformProcessedStepsToTimelineFlow = (processedSteps: VisualizerStep[], agentNameMap: Record = {}): FlowData => { - const newNodes: Node[] = []; - const newEdges: Edge[] = []; - - if (!processedSteps || processedSteps.length === 0) { - return { nodes: newNodes, edges: newEdges }; - } - - // Initialize edge animation service - const edgeAnimationService = new EdgeAnimationService(); - - const manager: TimelineLayoutManager = { - phases: [], - currentPhaseIndex: -1, - currentSubflowIndex: -1, - parallelFlows: new Map(), - nextAvailableGlobalY: Y_START, - nodeIdCounter: 0, - allCreatedNodeIds: new Set(), - nodePositions: new Map(), - allUserNodes: [], - userNodeCounter: 0, - agentRegistry: createAgentRegistry(), - indentationLevel: 0, - indentationStep: 50, // Pixels to indent per level - agentNameMap: agentNameMap, - }; - - const filteredSteps = processedSteps.filter(step => RELEVANT_STEP_TYPES.includes(step.type)); - - // Ensure the first USER_REQUEST step is processed first - const firstUserRequestIndex = filteredSteps.findIndex(step => step.type === "USER_REQUEST"); - let reorderedSteps = filteredSteps; - - if (firstUserRequestIndex > 0) { - // Move the first USER_REQUEST to the beginning - const firstUserRequest = filteredSteps[firstUserRequestIndex]; - reorderedSteps = [firstUserRequest, ...filteredSteps.slice(0, firstUserRequestIndex), ...filteredSteps.slice(firstUserRequestIndex + 1)]; - } - - for (const step of reorderedSteps) { - // Special handling for AGENT_LLM_RESPONSE_TOOL_DECISION if it's a peer delegation trigger - // This step often precedes AGENT_TOOL_INVOCATION_START for peers. - // The plan implies AGENT_TOOL_INVOCATION_START is the primary trigger for peer delegation. - // For now, we rely on AGENT_TOOL_INVOCATION_START to have enough info. - - switch (step.type) { - case "USER_REQUEST": - handleUserRequest(step, manager, newNodes, newEdges, edgeAnimationService, processedSteps); - break; - case "AGENT_LLM_CALL": - handleLLMCall(step, manager, newNodes, newEdges, edgeAnimationService, processedSteps); - break; - case "AGENT_LLM_RESPONSE_TO_AGENT": - case "AGENT_LLM_RESPONSE_TOOL_DECISION": - handleLLMResponseToAgent(step, manager, newNodes, newEdges, edgeAnimationService, processedSteps); - break; - case "AGENT_TOOL_INVOCATION_START": - handleToolInvocationStart(step, manager, newNodes, newEdges, edgeAnimationService, processedSteps); - break; - case "AGENT_TOOL_EXECUTION_RESULT": - handleToolExecutionResult(step, manager, newNodes, newEdges, edgeAnimationService, processedSteps); - break; - case "AGENT_RESPONSE_TEXT": - handleAgentResponseText(step, manager, newNodes, newEdges, edgeAnimationService, processedSteps); - break; - case "AGENT_ARTIFACT_NOTIFICATION": - handleArtifactNotification(step, manager, newNodes, newEdges, edgeAnimationService, processedSteps); - break; - case "TASK_COMPLETED": - handleTaskCompleted(step, manager, newNodes, newEdges, edgeAnimationService, processedSteps); - break; - case "TASK_FAILED": - handleTaskFailed(step, manager, newNodes, newEdges); - break; - } - } - - // Update group node heights based on final maxYInSubflow - manager.phases.forEach(phase => { - phase.subflows.forEach(subflow => { - const groupNodeData = newNodes.find(n => n.id === subflow.groupNode.id); - if (groupNodeData && groupNodeData.style) { - // Update Height - // peerAgent.yPosition is absolute, subflow.maxY is absolute. - // groupNode.yPosition is absolute. - // Content height is from top of first element (peerAgent) to bottom of last element in subflow. - // Relative Y of peer agent is GROUP_PADDING_Y. - // Max Y of content relative to group top = subflow.maxY - subflow.groupNode.yPosition - const contentMaxYRelative = subflow.maxY - subflow.groupNode.yPosition; - const requiredGroupHeight = contentMaxYRelative + GROUP_PADDING_Y; // Add bottom padding - groupNodeData.style.height = `${Math.max(NODE_HEIGHT + 2 * GROUP_PADDING_Y, requiredGroupHeight)}px`; - - // Update Width - // Ensure the group width is sufficient to contain all indented tool nodes - const requiredGroupWidth = subflow.maxContentXRelative + GROUP_PADDING_X; - - // Add extra padding to ensure the group is wide enough for indented tools - const minRequiredWidth = NODE_WIDTH + 2 * GROUP_PADDING_X + manager.indentationLevel * manager.indentationStep; - - groupNodeData.style.width = `${Math.max(requiredGroupWidth, minRequiredWidth)}px`; - } - }); - }); - - return { nodes: newNodes, edges: newEdges }; -}; diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/utils/layoutEngine.ts b/client/webui/frontend/src/lib/components/activities/FlowChart/utils/layoutEngine.ts new file mode 100644 index 000000000..27117ee63 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/utils/layoutEngine.ts @@ -0,0 +1,1545 @@ +import type { VisualizerStep } from "@/lib/types"; +import type { LayoutNode, Edge, BuildContext, LayoutResult } from "./types"; + +// Layout constants +const NODE_WIDTHS = { + AGENT: 220, + TOOL: 180, + LLM: 180, + USER: 140, + SWITCH: 120, + LOOP: 120, + MAP: 120, + MIN_AGENT_CONTENT: 200, +}; + +const NODE_HEIGHTS = { + AGENT_HEADER: 50, + TOOL: 50, + LLM: 50, + USER: 50, + SWITCH: 80, + LOOP: 80, + MAP: 80, +}; + +const SPACING = { + VERTICAL: 16, // Space between children within agent + HORIZONTAL: 20, // Space between parallel branches + AGENT_VERTICAL: 60, // Space between top-level agents + PADDING: 20, // Padding inside agent nodes +}; + +/** + * Main entry point: Process VisualizerSteps into layout tree + */ +export function processSteps(steps: VisualizerStep[], agentNameMap: Record = {}): LayoutResult { + const context: BuildContext = { + steps, + stepIndex: 0, + nodeCounter: 0, + taskToNodeMap: new Map(), + functionCallToNodeMap: new Map(), + currentAgentNode: null, + rootNodes: [], + agentNameMap, + parallelContainerMap: new Map(), + currentBranchMap: new Map(), + hasTopUserNode: false, + hasBottomUserNode: false, + parallelPeerGroupMap: new Map(), + parallelBlockMap: new Map(), + subWorkflowParentMap: new Map(), + }; + + // Process all steps to build tree structure + for (let i = 0; i < steps.length; i++) { + context.stepIndex = i; + const step = steps[i]; + processStep(step, context); + } + + // DEBUG: Print tree structure + console.log('=== LAYOUT ENGINE DEBUG ==='); + console.log('Steps processed:', steps.length); + console.log('Root nodes:', context.rootNodes.length); + console.log('taskToNodeMap keys:', Array.from(context.taskToNodeMap.keys())); + console.log('subWorkflowParentMap keys:', Array.from(context.subWorkflowParentMap.keys())); + + const printTree = (node: LayoutNode, indent: string = '') => { + console.log(`${indent}[${node.type}] ${node.data.label} (id=${node.id}, owningTaskId=${node.owningTaskId || 'none'})`); + for (const child of node.children) { + printTree(child, indent + ' '); + } + }; + + console.log('Tree structure:'); + for (const node of context.rootNodes) { + printTree(node); + } + console.log('=== END DEBUG ==='); + + // Calculate layout (positions and dimensions) + const nodes = calculateLayout(context.rootNodes); + + // Calculate edges between top-level nodes + const edges = calculateEdges(nodes, steps); + + // Calculate total canvas size + const { totalWidth, totalHeight } = calculateCanvasSize(nodes); + + return { + nodes, + edges, + totalWidth, + totalHeight, + }; +} + +/** + * Process a single VisualizerStep + */ +function processStep(step: VisualizerStep, context: BuildContext): void { + // Log workflow-related steps + if (step.type.startsWith('WORKFLOW')) { + console.log('[processStep]', step.type, 'owningTaskId=', step.owningTaskId, 'data=', step.data); + } + + switch (step.type) { + case "USER_REQUEST": + handleUserRequest(step, context); + break; + case "AGENT_LLM_CALL": + handleLLMCall(step, context); + break; + case "AGENT_TOOL_INVOCATION_START": + handleToolInvocation(step, context); + break; + case "AGENT_TOOL_EXECUTION_RESULT": + handleToolResult(step, context); + break; + case "AGENT_LLM_RESPONSE_TO_AGENT": + case "AGENT_LLM_RESPONSE_TOOL_DECISION": + handleLLMResponse(step, context); + break; + case "AGENT_RESPONSE_TEXT": + handleAgentResponse(step, context); + break; + case "WORKFLOW_EXECUTION_START": + handleWorkflowStart(step, context); + break; + case "WORKFLOW_NODE_EXECUTION_START": + handleWorkflowNodeStart(step, context); + break; + case "WORKFLOW_EXECUTION_RESULT": + handleWorkflowExecutionResult(step, context); + break; + case "WORKFLOW_NODE_EXECUTION_RESULT": + handleWorkflowNodeResult(step, context); + break; + case "AGENT_ARTIFACT_NOTIFICATION": + handleArtifactNotification(step, context); + break; + // Add other cases as needed + } +} + +/** + * Handle USER_REQUEST step - creates User node + Agent node + */ +function handleUserRequest(step: VisualizerStep, context: BuildContext): void { + // Only create top User node once, and only for top-level requests + if (!context.hasTopUserNode && step.nestingLevel === 0) { + const userNode = createNode( + context, + 'user', + { + label: 'User', + visualizerStepId: step.id, + isTopNode: true, + }, + step.owningTaskId + ); + context.rootNodes.push(userNode); + context.hasTopUserNode = true; + } + + // Create Agent node + const agentName = step.target || 'Agent'; + const displayName = context.agentNameMap[agentName] || agentName; + + const agentNode = createNode( + context, + 'agent', + { + label: displayName, + visualizerStepId: step.id, + }, + step.owningTaskId + ); + + // Add agent to root nodes + context.rootNodes.push(agentNode); + + // Set as current agent + context.currentAgentNode = agentNode; + + // Map task ID to this agent + if (step.owningTaskId) { + context.taskToNodeMap.set(step.owningTaskId, agentNode); + } +} + +/** + * Handle AGENT_LLM_CALL - adds LLM child to current agent + */ +function handleLLMCall(step: VisualizerStep, context: BuildContext): void { + const agentNode = findAgentForStep(step, context); + if (!agentNode) return; + + const llmNode = createNode( + context, + 'llm', + { + label: 'LLM', + visualizerStepId: step.id, + status: 'in-progress', + }, + step.owningTaskId + ); + + // Add as child + agentNode.children.push(llmNode); + + // Track by functionCallId for result matching + if (step.functionCallId) { + context.functionCallToNodeMap.set(step.functionCallId, llmNode); + } +} + +/** + * Handle AGENT_LLM_RESPONSE_TO_AGENT or AGENT_LLM_RESPONSE_TOOL_DECISION - marks LLM as completed + */ +function handleLLMResponse(step: VisualizerStep, context: BuildContext): void { + const agentNode = findAgentForStep(step, context); + if (!agentNode) { + return; + } + + // Find the most recent LLM node in this agent and mark it as completed + let foundInProgressLLM = false; + for (let i = agentNode.children.length - 1; i >= 0; i--) { + const child = agentNode.children[i]; + if (child.type === 'llm' && child.data.status === 'in-progress') { + child.data.status = 'completed'; + foundInProgressLLM = true; + break; + } + } + + // If no in-progress LLM was found, it means we received an LLM response without + // a corresponding AGENT_LLM_CALL. This can happen if the llm_invocation signal + // wasn't emitted. Create a synthetic LLM node to represent this call. + if (!foundInProgressLLM) { + const syntheticLlmNode = createNode( + context, + 'llm', + { + label: 'LLM', + visualizerStepId: step.id, // Link to the response step since we don't have a call step + status: 'completed', + }, + step.owningTaskId + ); + + // Insert the LLM node before any parallel blocks (which are created by TOOL_DECISION) + // Find the position before the first parallelBlock child + let insertIndex = agentNode.children.length; + for (let i = 0; i < agentNode.children.length; i++) { + if (agentNode.children[i].type === 'parallelBlock') { + insertIndex = i; + break; + } + } + agentNode.children.splice(insertIndex, 0, syntheticLlmNode); + } + + // Check for parallel tool calls in TOOL_DECISION + if (step.type === 'AGENT_LLM_RESPONSE_TOOL_DECISION') { + const toolDecision = step.data.toolDecision; + if (toolDecision?.isParallel && toolDecision.decisions) { + // Filter for peer delegations + const peerDecisions = toolDecision.decisions.filter(d => d.isPeerDelegation); + + // Filter for workflow calls (non-peer, toolName contains 'workflow_') + const workflowDecisions = toolDecision.decisions.filter( + d => !d.isPeerDelegation && d.toolName.includes('workflow_') + ); + + // Handle parallel peer delegations + if (peerDecisions.length > 1) { + const groupKey = `${step.owningTaskId}:parallel-peer:${step.id}`; + const functionCallIds = new Set(peerDecisions.map(d => d.functionCallId)); + + context.parallelPeerGroupMap.set(groupKey, functionCallIds); + + const parallelBlockNode = createNode( + context, + 'parallelBlock', + { + label: 'Parallel', + visualizerStepId: step.id, + }, + step.owningTaskId + ); + + agentNode.children.push(parallelBlockNode); + context.parallelBlockMap.set(groupKey, parallelBlockNode); + } + + // Handle parallel workflow calls + if (workflowDecisions.length > 1) { + const groupKey = `${step.owningTaskId}:parallel-workflow:${step.id}`; + const functionCallIds = new Set(workflowDecisions.map(d => d.functionCallId)); + + context.parallelPeerGroupMap.set(groupKey, functionCallIds); + + const parallelBlockNode = createNode( + context, + 'parallelBlock', + { + label: 'Parallel', + visualizerStepId: step.id, + }, + step.owningTaskId + ); + + agentNode.children.push(parallelBlockNode); + context.parallelBlockMap.set(groupKey, parallelBlockNode); + } + } + } +} + +/** + * Handle AGENT_TOOL_INVOCATION_START + */ +function handleToolInvocation(step: VisualizerStep, context: BuildContext): void { + const isPeer = step.data.toolInvocationStart?.isPeerInvocation || step.target?.startsWith('peer_'); + const target = step.target || ''; + const toolName = step.data.toolInvocationStart?.toolName || target; + const parallelGroupId = step.data.toolInvocationStart?.parallelGroupId; + + // Skip workflow tools (handled separately) + if (target.includes('workflow_') || toolName.includes('workflow_')) { + return; + } + + const agentNode = findAgentForStep(step, context); + if (!agentNode) return; + + if (isPeer) { + // Create nested agent node + const peerName = target.startsWith('peer_') ? target.substring(5) : target; + const displayName = context.agentNameMap[peerName] || peerName; + + const subAgentNode = createNode( + context, + 'agent', + { + label: displayName, + visualizerStepId: step.id, + }, + step.delegationInfo?.[0]?.subTaskId || step.owningTaskId + ); + + // Check if this peer invocation is part of a parallel group + // First check for backend-provided parallelGroupId, then fall back to legacy detection + const functionCallId = step.data.toolInvocationStart?.functionCallId || step.functionCallId; + let addedToParallelBlock = false; + + // Use parallelGroupId from backend if available + if (parallelGroupId) { + let parallelBlock = context.parallelBlockMap.get(parallelGroupId); + if (!parallelBlock) { + // Create a new parallel block for this group + parallelBlock = createNode( + context, + 'parallelBlock', + { + label: 'Parallel', + visualizerStepId: step.id, + }, + step.owningTaskId + ); + context.parallelBlockMap.set(parallelGroupId, parallelBlock); + agentNode.children.push(parallelBlock); + } + parallelBlock.children.push(subAgentNode); + addedToParallelBlock = true; + } else if (functionCallId) { + // Fall back to legacy parallel peer group detection + for (const [groupKey, functionCallIds] of context.parallelPeerGroupMap.entries()) { + if (functionCallIds.has(functionCallId)) { + // This peer invocation is part of a parallel group + const parallelBlock = context.parallelBlockMap.get(groupKey); + if (parallelBlock) { + // Add the sub-agent as a child of the parallelBlock + parallelBlock.children.push(subAgentNode); + addedToParallelBlock = true; + } + break; + } + } + } + + // If not part of a parallel group, add as regular child + if (!addedToParallelBlock) { + agentNode.children.push(subAgentNode); + } + + // Map sub-task to this new agent + const subTaskId = step.delegationInfo?.[0]?.subTaskId; + if (subTaskId) { + context.taskToNodeMap.set(subTaskId, subAgentNode); + } + + // Track by functionCallId + if (functionCallId) { + context.functionCallToNodeMap.set(functionCallId, subAgentNode); + } + } else { + // Regular tool + const toolNode = createNode( + context, + 'tool', + { + label: toolName, + visualizerStepId: step.id, + status: 'in-progress', + }, + step.owningTaskId + ); + + // Check if this tool is part of a parallel group + if (parallelGroupId) { + let parallelBlock = context.parallelBlockMap.get(parallelGroupId); + if (!parallelBlock) { + // Create a new parallel block for this group + parallelBlock = createNode( + context, + 'parallelBlock', + { + label: 'Parallel Tools', + visualizerStepId: step.id, + }, + step.owningTaskId + ); + context.parallelBlockMap.set(parallelGroupId, parallelBlock); + agentNode.children.push(parallelBlock); + } + parallelBlock.children.push(toolNode); + } else { + agentNode.children.push(toolNode); + } + + // Use the tool's actual functionCallId from the data (preferred) for matching with tool_result + // The step.functionCallId is the parent tracking ID for sub-task relationships + const functionCallId = step.data.toolInvocationStart?.functionCallId || step.functionCallId; + if (functionCallId) { + context.functionCallToNodeMap.set(functionCallId, toolNode); + } + } +} + +/** + * Handle AGENT_TOOL_EXECUTION_RESULT - update status + */ +function handleToolResult(step: VisualizerStep, context: BuildContext): void { + const functionCallId = step.data.toolResult?.functionCallId || step.functionCallId; + if (!functionCallId) return; + + const node = context.functionCallToNodeMap.get(functionCallId); + if (node) { + node.data.status = 'completed'; + } +} + +/** + * Handle AGENT_ARTIFACT_NOTIFICATION - associate artifact with the tool that created it + */ +function handleArtifactNotification(step: VisualizerStep, context: BuildContext): void { + const functionCallId = step.functionCallId; + if (!functionCallId) return; + + const node = context.functionCallToNodeMap.get(functionCallId); + if (node && step.data.artifactNotification) { + if (!node.data.createdArtifacts) { + node.data.createdArtifacts = []; + } + node.data.createdArtifacts.push({ + filename: step.data.artifactNotification.artifactName, + version: step.data.artifactNotification.version, + mimeType: step.data.artifactNotification.mimeType, + description: step.data.artifactNotification.description, + }); + } +} + +/** + * Handle AGENT_RESPONSE_TEXT - create bottom User node (only once at the end) + */ +function handleAgentResponse(step: VisualizerStep, context: BuildContext): void { + // Only for top-level tasks + if (step.nestingLevel && step.nestingLevel > 0) return; + + // Only create bottom user node once, and only for the last response + // We'll check if this is the last top-level AGENT_RESPONSE_TEXT + const remainingSteps = context.steps.slice(context.stepIndex + 1); + const hasMoreTopLevelResponses = remainingSteps.some( + s => s.type === 'AGENT_RESPONSE_TEXT' && s.nestingLevel === 0 + ); + + if (!hasMoreTopLevelResponses && !context.hasBottomUserNode) { + const userNode = createNode( + context, + 'user', + { + label: 'User', + visualizerStepId: step.id, + isBottomNode: true, + }, + step.owningTaskId + ); + + context.rootNodes.push(userNode); + context.hasBottomUserNode = true; + } +} + +/** + * Handle WORKFLOW_EXECUTION_START + */ +function handleWorkflowStart(step: VisualizerStep, context: BuildContext): void { + const workflowName = step.data.workflowExecutionStart?.workflowName || 'Workflow'; + const displayName = context.agentNameMap[workflowName] || workflowName; + const executionId = step.data.workflowExecutionStart?.executionId; + + console.log('[handleWorkflowStart] workflowName=', workflowName, 'executionId=', executionId, 'owningTaskId=', step.owningTaskId, 'parentTaskId=', step.parentTaskId); + + // Check if this is a sub-workflow invoked by a parent workflow's 'workflow' node type + // The parent relationship is recorded in subWorkflowParentMap by handleWorkflowNodeType + const parentFromWorkflowNode = step.owningTaskId + ? context.subWorkflowParentMap.get(step.owningTaskId) + : null; + + console.log('[handleWorkflowStart] parentFromWorkflowNode=', parentFromWorkflowNode?.data.label, parentFromWorkflowNode?.id); + + // Find the calling agent - prefer the recorded parent from workflow node, + // then try parentTaskId lookup, then fall back to current agent + let callingAgent: LayoutNode | null = parentFromWorkflowNode || null; + if (!callingAgent && step.parentTaskId) { + callingAgent = context.taskToNodeMap.get(step.parentTaskId) || null; + console.log('[handleWorkflowStart] callingAgent from parentTaskId lookup=', callingAgent?.data.label); + } + if (!callingAgent) { + callingAgent = context.currentAgentNode; + console.log('[handleWorkflowStart] callingAgent from currentAgentNode=', callingAgent?.data.label); + } + + console.log('[handleWorkflowStart] Final callingAgent=', callingAgent?.data.label, callingAgent?.id); + + // Create group container + const groupNode = createNode( + context, + 'group', + { + label: displayName, + visualizerStepId: step.id, + }, + executionId || step.owningTaskId + ); + + // Create Start node inside group + const startNode = createNode( + context, + 'agent', + { + label: 'Start', + variant: 'pill', + visualizerStepId: step.id, + }, + step.owningTaskId + ); + + groupNode.children.push(startNode); + + // Check if this workflow is part of a parallel group + const functionCallId = step.functionCallId; + let addedToParallelBlock = false; + + if (functionCallId) { + // Search through all parallel groups to find if this functionCallId belongs to one + for (const [groupKey, functionCallIds] of context.parallelPeerGroupMap.entries()) { + if (functionCallIds.has(functionCallId)) { + // This workflow is part of a parallel group + const parallelBlock = context.parallelBlockMap.get(groupKey); + if (parallelBlock) { + parallelBlock.children.push(groupNode); + addedToParallelBlock = true; + } + break; + } + } + } + + // If not part of a parallel group, add to calling agent or root + if (!addedToParallelBlock) { + if (callingAgent) { + callingAgent.children.push(groupNode); + } else { + context.rootNodes.push(groupNode); + } + } + + // Map execution ID to group for workflow nodes + if (executionId) { + context.taskToNodeMap.set(executionId, groupNode); + } + + // Also map by owningTaskId so findAgentForStep can find the group + // when workflow node steps use the event's task_id as their owningTaskId + if (step.owningTaskId && step.owningTaskId !== executionId) { + context.taskToNodeMap.set(step.owningTaskId, groupNode); + } +} + +/** + * Handle WORKFLOW_NODE_EXECUTION_START + */ +function handleWorkflowNodeStart(step: VisualizerStep, context: BuildContext): void { + const nodeType = step.data.workflowNodeExecutionStart?.nodeType; + const nodeId = step.data.workflowNodeExecutionStart?.nodeId || 'unknown'; + const agentName = step.data.workflowNodeExecutionStart?.agentName; + const parentNodeId = step.data.workflowNodeExecutionStart?.parentNodeId; + const parallelGroupId = step.data.workflowNodeExecutionStart?.parallelGroupId; + const taskId = step.owningTaskId; + + // Check if this node is a child of a Map/Loop (parallel execution with parentNodeId) + // For Map/Loop children, use parallelGroupId if available, otherwise fall back to parentNodeId + // NOTE: For implicit parallel agents (no parentNodeId), we don't look up in parallelContainerMap + // because they find their container via parallelBlockMap using parallelGroupId directly. + const isMapOrLoopChild = parentNodeId !== undefined && parentNodeId !== null; + const parallelContainerKey = isMapOrLoopChild + ? (parallelGroupId || `${taskId}:${parentNodeId}`) + : null; + const parallelContainer = parallelContainerKey ? context.parallelContainerMap.get(parallelContainerKey) : null; + + // Handle workflow nodes specially - they invoke sub-workflows and need group styling + if (nodeType === 'workflow') { + handleWorkflowNodeType(step, context); + return; + } + + // Determine node type and variant + let type: LayoutNode['type'] = 'agent'; + const variant: 'default' | 'pill' = 'default'; + let label: string; + + if (nodeType === 'switch') { + type = 'switch'; + label = 'Switch'; + } else if (nodeType === 'loop') { + type = 'loop'; + label = 'Loop'; + } else if (nodeType === 'map') { + type = 'map'; + label = 'Map'; + } else { + // Agent nodes use their actual name + label = agentName || nodeId; + } + + const workflowNodeData = step.data.workflowNodeExecutionStart; + const workflowNode = createNode( + context, + type, + { + label, + variant, + visualizerStepId: step.id, + // Conditional node fields + condition: workflowNodeData?.condition, + trueBranch: workflowNodeData?.trueBranch, + falseBranch: workflowNodeData?.falseBranch, + // Switch node fields + cases: workflowNodeData?.cases, + defaultBranch: workflowNodeData?.defaultBranch, + // Loop node fields + maxIterations: workflowNodeData?.maxIterations, + loopDelay: workflowNodeData?.loopDelay, + // Store the original nodeId for reference when clicked + nodeId, + }, + step.owningTaskId + ); + + // For agent nodes within workflows, create a sub-task context + if (nodeType === 'agent') { + const subTaskId = step.data.workflowNodeExecutionStart?.subTaskId; + if (subTaskId) { + context.taskToNodeMap.set(subTaskId, workflowNode); + } + } + + // Handle Map nodes - these create parallel branches + if (nodeType === 'map') { + // Find parent group + const groupNode = findAgentForStep(step, context); + if (!groupNode) return; + + // Store in parallel container map for child nodes to find + // Use parallelGroupId from backend if available, otherwise use legacy key format + const containerKey = parallelGroupId || `${taskId}:${nodeId}`; + context.parallelContainerMap.set(containerKey, workflowNode); + + // Add to parent group + groupNode.children.push(workflowNode); + } + // Handle Loop nodes - these contain sequential iterations + else if (nodeType === 'loop') { + // Find parent group + const groupNode = findAgentForStep(step, context); + if (!groupNode) return; + + // Store in parallel container map so child nodes can find their parent + // Loop children will be added sequentially to this node's children array + const containerKey = `${taskId}:${nodeId}`; + context.parallelContainerMap.set(containerKey, workflowNode); + + // Add to parent group + groupNode.children.push(workflowNode); + } + // Handle nodes that are children of Map/Loop + else if (parallelContainer) { + // Check if parent is a loop (sequential children) or map (parallel branches) + if (parallelContainer.type === 'loop') { + // Loop iterations are sequential - add as direct children + parallelContainer.children.push(workflowNode); + } else { + // Map have parallel branches - store in children with iterationIndex metadata + const iterationIndex = step.data.workflowNodeExecutionStart?.iterationIndex ?? 0; + workflowNode.data.iterationIndex = iterationIndex; + parallelContainer.children.push(workflowNode); + } + } + // Handle implicit parallel agent nodes (from backend parallel_group_id) + else if (nodeType === 'agent' && parallelGroupId) { + // Find parent group + const groupNode = findAgentForStep(step, context); + if (!groupNode) return; + + // Check if we already have a parallel block for this group + let implicitParallelBlock = context.parallelBlockMap.get(parallelGroupId); + if (!implicitParallelBlock) { + // Create a new parallel block container for this implicit fork + implicitParallelBlock = createNode( + context, + 'parallelBlock', + { + label: 'Parallel', + visualizerStepId: step.id, + }, + step.owningTaskId + ); + context.parallelBlockMap.set(parallelGroupId, implicitParallelBlock); + // NOTE: Do NOT store in parallelContainerMap - that's only for Map/Loop containers + // where children have parentNodeId relationship. Implicit parallel agents find + // their container via parallelBlockMap using parallelGroupId. + groupNode.children.push(implicitParallelBlock); + } + + // Add this agent node to the parallel block with its branch index + const iterationIndex = step.data.workflowNodeExecutionStart?.iterationIndex ?? 0; + workflowNode.data.iterationIndex = iterationIndex; + implicitParallelBlock.children.push(workflowNode); + } + // Regular workflow node (not in parallel context) + else { + // Find parent group + const groupNode = findAgentForStep(step, context); + if (!groupNode) return; + + groupNode.children.push(workflowNode); + } +} + +/** + * Handle workflow node type - records parent relationship for sub-workflow invocation + * The actual group creation happens in handleWorkflowStart when the sub-workflow starts + */ +function handleWorkflowNodeType(step: VisualizerStep, context: BuildContext): void { + const workflowNodeData = step.data.workflowNodeExecutionStart; + const subTaskId = workflowNodeData?.subTaskId; + + console.log('[handleWorkflowNodeType] nodeType=workflow, subTaskId=', subTaskId, 'owningTaskId=', step.owningTaskId); + + if (!subTaskId) { + console.log('[handleWorkflowNodeType] No subTaskId, returning'); + return; + } + + // Find the parent workflow group + const parentGroup = findAgentForStep(step, context); + console.log('[handleWorkflowNodeType] parentGroup=', parentGroup?.data.label, parentGroup?.id); + if (!parentGroup) { + console.log('[handleWorkflowNodeType] No parent group found, returning'); + return; + } + + // Record the parent relationship so handleWorkflowStart can use it + // This allows the sub-workflow's WORKFLOW_EXECUTION_START to find the correct parent + context.subWorkflowParentMap.set(subTaskId, parentGroup); + console.log('[handleWorkflowNodeType] Recorded mapping:', subTaskId, '->', parentGroup.data.label); +} + +/** + * Handle WORKFLOW_EXECUTION_RESULT - creates Finish node + */ +function handleWorkflowExecutionResult(step: VisualizerStep, context: BuildContext): void { + // Find the workflow group node by owningTaskId (which should be the execution ID) + const groupNode = findAgentForStep(step, context); + if (!groupNode) return; + + // Get the execution result to determine status + const resultData = step.data.workflowExecutionResult; + // Backend may send 'error' or 'failure' for failures, 'success' for success + const isError = resultData?.status === 'error' || resultData?.status === 'failure'; + const nodeStatus = isError ? 'error' : 'completed'; + + // Create Finish node with status + const finishNode = createNode( + context, + 'agent', + { + label: 'Finish', + variant: 'pill', + visualizerStepId: step.id, + status: nodeStatus, + }, + step.owningTaskId + ); + + groupNode.children.push(finishNode); +} + +/** + * Handle WORKFLOW_NODE_EXECUTION_RESULT - cleanup, update node status, and add Join node + */ +function handleWorkflowNodeResult(step: VisualizerStep, context: BuildContext): void { + const resultData = step.data.workflowNodeExecutionResult; + const nodeId = resultData?.nodeId; + const taskId = step.owningTaskId; + + if (!nodeId) return; + + const containerKey = `${taskId}:${nodeId}`; + const parallelContainer = context.parallelContainerMap.get(containerKey); + + // Find the workflow node that matches this result and update its data + const groupNode = findAgentForStep(step, context); + if (groupNode) { + const targetNode = findNodeById(groupNode, nodeId); + if (targetNode) { + // Update status + targetNode.data.status = resultData?.status === 'success' ? 'completed' : + resultData?.status === 'failure' ? 'error' : 'completed'; + + // Update switch node with selected branch + if (targetNode.type === 'switch') { + const selectedBranch = resultData?.metadata?.selected_branch; + const selectedCaseIndex = resultData?.metadata?.selected_case_index; + if (selectedBranch !== undefined) { + targetNode.data.selectedBranch = selectedBranch; + } + if (selectedCaseIndex !== undefined) { + targetNode.data.selectedCaseIndex = selectedCaseIndex; + } + } + } + } + + // If this result is for a parallel container (Map/Fork/Loop), clean up tracking + if (parallelContainer) { + context.parallelContainerMap.delete(containerKey); + } + + // For agent node results, mark any remaining in-progress LLM nodes as completed + // This handles the case where the final LLM response doesn't emit a separate event + const nodeType = resultData?.metadata?.node_type; + if (nodeType === 'agent' || !parallelContainer) { + // Find the agent node for this workflow node by looking for it in the task map + // The agent node was registered with its subTaskId + for (const [subTaskId, agentNode] of context.taskToNodeMap.entries()) { + // Check if this subTaskId matches the pattern for this nodeId + if (subTaskId.includes(nodeId) || agentNode.data.nodeId === nodeId) { + // Mark all in-progress LLM children as completed + for (const child of agentNode.children) { + if (child.type === 'llm' && child.data.status === 'in-progress') { + child.data.status = 'completed'; + } + } + break; + } + } + } +} + +/** + * Find a node by its nodeId within a tree + */ +function findNodeById(root: LayoutNode, nodeId: string): LayoutNode | null { + // Check if this node matches + if (root.data.nodeId === nodeId) { + return root; + } + + // Search children + for (const child of root.children) { + const found = findNodeById(child, nodeId); + if (found) return found; + } + + // Search parallel branches + if (root.parallelBranches) { + for (const branch of root.parallelBranches) { + for (const branchNode of branch) { + const found = findNodeById(branchNode, nodeId); + if (found) return found; + } + } + } + + return null; +} + +/** + * Find the appropriate agent node for a step + */ +function findAgentForStep(step: VisualizerStep, context: BuildContext): LayoutNode | null { + // Try owningTaskId first + if (step.owningTaskId) { + const node = context.taskToNodeMap.get(step.owningTaskId); + if (node) return node; + } + + // Fallback to current agent + return context.currentAgentNode; +} + +/** + * Create a new node + */ +function createNode( + context: BuildContext, + type: LayoutNode['type'], + data: LayoutNode['data'], + owningTaskId?: string +): LayoutNode { + const id = `${type}_${context.nodeCounter++}`; + + return { + id, + type, + data, + x: 0, + y: 0, + width: 0, + height: 0, + children: [], + owningTaskId, + }; +} + +/** + * Calculate layout (positions and dimensions) for all nodes + */ +function calculateLayout(rootNodes: LayoutNode[]): LayoutNode[] { + // First pass: measure all nodes to find max width + let maxWidth = 0; + for (const node of rootNodes) { + measureNode(node); + maxWidth = Math.max(maxWidth, node.width); + } + + // Calculate center X position based on max width + const centerX = maxWidth / 2 + 100; // Add margin + + // Second pass: position nodes centered + let currentY = 50; // Start with offset from top + + for (let i = 0; i < rootNodes.length; i++) { + const node = rootNodes[i]; + const nextNode = rootNodes[i + 1]; + + // Center each node horizontally + node.x = centerX - node.width / 2; + node.y = currentY; + positionNode(node); + + // Use smaller spacing for User nodes (connector line spacing) + // Use larger spacing between agents + let spacing = SPACING.AGENT_VERTICAL; + if (node.type === 'user' || (nextNode && nextNode.type === 'user')) { + spacing = SPACING.VERTICAL; + } + + currentY = node.y + node.height + spacing; + } + + return rootNodes; +} + +/** + * Measure node dimensions (recursive, bottom-up) + */ +function measureNode(node: LayoutNode): void { + // First, measure all children + for (const child of node.children) { + measureNode(child); + } + + // Handle parallel branches + if (node.parallelBranches) { + for (const branch of node.parallelBranches) { + for (const branchNode of branch) { + measureNode(branchNode); + } + } + } + + // Calculate this node's dimensions based on type + switch (node.type) { + case 'agent': + measureAgentNode(node); + break; + case 'tool': + node.width = NODE_WIDTHS.TOOL; + node.height = NODE_HEIGHTS.TOOL; + break; + case 'llm': + node.width = NODE_WIDTHS.LLM; + node.height = NODE_HEIGHTS.LLM; + break; + case 'user': + node.width = NODE_WIDTHS.USER; + node.height = NODE_HEIGHTS.USER; + break; + case 'switch': + node.width = NODE_WIDTHS.SWITCH; + node.height = NODE_HEIGHTS.SWITCH; + break; + case 'loop': + measureLoopNode(node); + break; + case 'map': + measureMapNode(node); + break; + case 'group': + measureGroupNode(node); + break; + case 'parallelBlock': + measureParallelBlockNode(node); + break; + } +} + +/** + * Measure agent node (container with children) + */ +function measureAgentNode(node: LayoutNode): void { + let contentWidth = NODE_WIDTHS.MIN_AGENT_CONTENT; + let contentHeight = 0; + + // If it's a pill variant (Start/Finish/Join), use smaller dimensions + if (node.data.variant === 'pill') { + node.width = 100; + node.height = 40; + return; + } + + // Measure sequential children + if (node.children.length > 0) { + for (const child of node.children) { + contentWidth = Math.max(contentWidth, child.width); + contentHeight += child.height + SPACING.VERTICAL; + } + // Remove last spacing + contentHeight -= SPACING.VERTICAL; + } + + // Measure parallel branches + if (node.parallelBranches && node.parallelBranches.length > 0) { + // Add spacing between children and parallel branches if both exist + if (node.children.length > 0) { + contentHeight += SPACING.VERTICAL; + } + + let branchWidth = 0; + let maxBranchHeight = 0; + + for (const branch of node.parallelBranches) { + let branchHeight = 0; + let branchMaxWidth = 0; + + for (const branchNode of branch) { + branchHeight += branchNode.height + SPACING.VERTICAL; + branchMaxWidth = Math.max(branchMaxWidth, branchNode.width); + } + + // Remove last spacing from branch height + if (branch.length > 0) { + branchHeight -= SPACING.VERTICAL; + } + + branchWidth += branchMaxWidth + SPACING.HORIZONTAL; + maxBranchHeight = Math.max(maxBranchHeight, branchHeight); + } + + // Remove last horizontal spacing + if (node.parallelBranches.length > 0) { + branchWidth -= SPACING.HORIZONTAL; + } + + contentWidth = Math.max(contentWidth, branchWidth); + contentHeight += maxBranchHeight; + } + + // Add header height and padding + node.width = contentWidth + (SPACING.PADDING * 2); + node.height = NODE_HEIGHTS.AGENT_HEADER + contentHeight + SPACING.PADDING; +} + +/** + * Measure loop node - can be a badge or a container with children + */ +function measureLoopNode(node: LayoutNode): void { + // If no children, use badge dimensions + if (node.children.length === 0) { + node.width = NODE_WIDTHS.LOOP; + node.height = NODE_HEIGHTS.LOOP; + return; + } + + // Has children - measure as a container + let contentWidth = 200; + let contentHeight = 0; + + // Account for iteration labels (about 16px per iteration for the label) + const iterationLabelHeight = 16; + + for (const child of node.children) { + contentWidth = Math.max(contentWidth, child.width); + contentHeight += iterationLabelHeight + child.height + SPACING.VERTICAL; + } + + if (node.children.length > 0) { + contentHeight -= SPACING.VERTICAL; + } + + // Loop uses p-4 pt-3 (16px padding, 12px top) + const loopPadding = 16; + const topLabelOffset = -4; // pt-3 is less than p-4, so negative offset + node.width = contentWidth + (loopPadding * 2); + node.height = contentHeight + loopPadding + topLabelOffset + loopPadding; +} + +/** + * Measure map node - can be a badge or a container with parallel branches + * Children are stored in node.children with iterationIndex in their data + */ +function measureMapNode(node: LayoutNode): void { + // Group children by iterationIndex + const branches = new Map(); + for (const child of node.children) { + const iterationIndex = child.data.iterationIndex ?? 0; + if (!branches.has(iterationIndex)) { + branches.set(iterationIndex, []); + } + branches.get(iterationIndex)!.push(child); + } + + // If no children, use badge dimensions + if (branches.size === 0) { + node.width = NODE_WIDTHS.MAP; + node.height = NODE_HEIGHTS.MAP; + return; + } + + // Has children - measure as a container with side-by-side branches + let totalWidth = 0; + let maxBranchHeight = 0; + + // Account for iteration labels (about 20px per branch for the label) + const iterationLabelHeight = 20; + + // Sort branches by iteration index for consistent ordering + const sortedBranches = Array.from(branches.entries()).sort((a, b) => a[0] - b[0]); + + for (const [, branchChildren] of sortedBranches) { + let branchWidth = 0; + let branchHeight = iterationLabelHeight; // Start with label height + + for (const child of branchChildren) { + branchWidth = Math.max(branchWidth, child.width); + branchHeight += child.height + SPACING.VERTICAL; + } + + // Remove last spacing from branch + if (branchChildren.length > 0) { + branchHeight -= SPACING.VERTICAL; + } + + totalWidth += branchWidth + SPACING.HORIZONTAL; + maxBranchHeight = Math.max(maxBranchHeight, branchHeight); + } + + // Remove last horizontal spacing + if (sortedBranches.length > 0) { + totalWidth -= SPACING.HORIZONTAL; + } + + // Map uses p-4 pt-3 (16px padding, 12px top) + const containerPadding = 16; + const topLabelOffset = -4; // pt-3 is less than p-4, so negative offset + node.width = totalWidth + (containerPadding * 2); + node.height = maxBranchHeight + containerPadding + topLabelOffset + containerPadding; +} + +/** + * Measure group node + */ +function measureGroupNode(node: LayoutNode): void { + let contentWidth = 200; + let contentHeight = 0; + + for (const child of node.children) { + contentWidth = Math.max(contentWidth, child.width); + contentHeight += child.height + SPACING.VERTICAL; + } + + if (node.children.length > 0) { + contentHeight -= SPACING.VERTICAL; + } + + // Group uses p-6 (24px) padding in WorkflowGroup + const groupPadding = 24; + node.width = contentWidth + (groupPadding * 2); + node.height = contentHeight + (groupPadding * 2); +} + +/** + * Measure parallel block node - children are displayed side-by-side with bounding box + * Children are grouped by iterationIndex (branch index) for proper chain visualization + */ +function measureParallelBlockNode(node: LayoutNode): void { + // Group children by iterationIndex to form branch chains + const branches = new Map(); + for (const child of node.children) { + const branchIdx = child.data.iterationIndex ?? 0; + if (!branches.has(branchIdx)) { + branches.set(branchIdx, []); + } + branches.get(branchIdx)!.push(child); + } + + // If only one branch or no iterationIndex grouping, fall back to side-by-side + if (branches.size <= 1 && node.children.every(c => c.data.iterationIndex === undefined)) { + let totalWidth = 0; + let maxHeight = 0; + + for (const child of node.children) { + totalWidth += child.width + SPACING.HORIZONTAL; + maxHeight = Math.max(maxHeight, child.height); + } + + if (node.children.length > 0) { + totalWidth -= SPACING.HORIZONTAL; + } + + const blockPadding = 16; + node.width = totalWidth + (blockPadding * 2); + node.height = maxHeight + (blockPadding * 2); + return; + } + + // Multiple branches - measure each branch (stacked vertically) and place side-by-side + let totalWidth = 0; + let maxBranchHeight = 0; + + const sortedBranches = Array.from(branches.entries()).sort((a, b) => a[0] - b[0]); + for (const [, branchChildren] of sortedBranches) { + let branchWidth = 0; + let branchHeight = 0; + + for (const child of branchChildren) { + branchWidth = Math.max(branchWidth, child.width); + branchHeight += child.height + SPACING.VERTICAL; + } + + if (branchChildren.length > 0) { + branchHeight -= SPACING.VERTICAL; + } + + totalWidth += branchWidth + SPACING.HORIZONTAL; + maxBranchHeight = Math.max(maxBranchHeight, branchHeight); + } + + if (sortedBranches.length > 0) { + totalWidth -= SPACING.HORIZONTAL; + } + + const blockPadding = 16; + node.width = totalWidth + (blockPadding * 2); + node.height = maxBranchHeight + (blockPadding * 2); +} + +/** + * Position children within node (recursive, top-down) + */ +function positionNode(node: LayoutNode): void { + if (node.type === 'agent' && node.data.variant !== 'pill') { + // Position children inside agent + let currentY = node.y + NODE_HEIGHTS.AGENT_HEADER + SPACING.PADDING; + const centerX = node.x + node.width / 2; + + for (const child of node.children) { + child.x = centerX - child.width / 2; // Center horizontally + child.y = currentY; + positionNode(child); // Recursive + currentY += child.height + SPACING.VERTICAL; + } + + // Position parallel branches side-by-side + if (node.parallelBranches) { + let branchX = node.x + SPACING.PADDING; + + for (const branch of node.parallelBranches) { + let branchMaxWidth = 0; + let branchY = currentY; + + for (const branchNode of branch) { + branchNode.x = branchX; + branchNode.y = branchY; + positionNode(branchNode); + branchY += branchNode.height + SPACING.VERTICAL; + branchMaxWidth = Math.max(branchMaxWidth, branchNode.width); + } + + branchX += branchMaxWidth + SPACING.HORIZONTAL; + } + } + } else if (node.type === 'group') { + // Position children inside group + let currentY = node.y + SPACING.PADDING + 30; // Offset for label + const centerX = node.x + node.width / 2; + + for (const child of node.children) { + child.x = centerX - child.width / 2; + child.y = currentY; + positionNode(child); + currentY += child.height + SPACING.VERTICAL; + } + } else if (node.type === 'parallelBlock') { + // Group children by iterationIndex to form branch chains + const branches = new Map(); + for (const child of node.children) { + const branchIdx = child.data.iterationIndex ?? 0; + if (!branches.has(branchIdx)) { + branches.set(branchIdx, []); + } + branches.get(branchIdx)!.push(child); + } + + const blockPadding = 16; + + // If only one branch or no iterationIndex grouping, position side-by-side + if (branches.size <= 1 && node.children.every(c => c.data.iterationIndex === undefined)) { + let currentX = node.x + blockPadding; + for (const child of node.children) { + child.x = currentX; + child.y = node.y + blockPadding; + positionNode(child); + currentX += child.width + SPACING.HORIZONTAL; + } + } else { + // Multiple branches - position each branch vertically, branches side-by-side + const sortedBranches = Array.from(branches.entries()).sort((a, b) => a[0] - b[0]); + let currentX = node.x + blockPadding; + + for (const [, branchChildren] of sortedBranches) { + let currentY = node.y + blockPadding; + let branchMaxWidth = 0; + + for (const child of branchChildren) { + child.x = currentX; + child.y = currentY; + positionNode(child); + currentY += child.height + SPACING.VERTICAL; + branchMaxWidth = Math.max(branchMaxWidth, child.width); + } + + currentX += branchMaxWidth + SPACING.HORIZONTAL; + } + } + } else if (node.type === 'loop' && node.children.length > 0) { + // Position children inside loop container + const loopPadding = 16; + const topLabelOffset = -4; // pt-3 is less than p-4 + const iterationLabelHeight = 16; + let currentY = node.y + loopPadding + topLabelOffset; + const centerX = node.x + node.width / 2; + + for (const child of node.children) { + // Account for iteration label + currentY += iterationLabelHeight; + child.x = centerX - child.width / 2; + child.y = currentY; + positionNode(child); + currentY += child.height + SPACING.VERTICAL; + } + } else if (node.type === 'map' && node.children.length > 0) { + // Group children by iterationIndex for positioning + const branches = new Map(); + for (const child of node.children) { + const iterationIndex = child.data.iterationIndex ?? 0; + if (!branches.has(iterationIndex)) { + branches.set(iterationIndex, []); + } + branches.get(iterationIndex)!.push(child); + } + + // Position parallel branches side-by-side inside map container + const containerPadding = 16; + const topLabelOffset = -4; // pt-3 is less than p-4 + const iterationLabelHeight = 20; + let currentX = node.x + containerPadding; + const startY = node.y + containerPadding + topLabelOffset; + + // Sort branches by iteration index for consistent ordering + const sortedBranches = Array.from(branches.entries()).sort((a, b) => a[0] - b[0]); + + for (const [, branchChildren] of sortedBranches) { + let branchMaxWidth = 0; + let currentY = startY + iterationLabelHeight; // Start below iteration label + + for (const child of branchChildren) { + child.x = currentX; + child.y = currentY; + positionNode(child); + currentY += child.height + SPACING.VERTICAL; + branchMaxWidth = Math.max(branchMaxWidth, child.width); + } + + currentX += branchMaxWidth + SPACING.HORIZONTAL; + } + } +} + +/** + * Calculate edges between nodes + */ +function calculateEdges(nodes: LayoutNode[], _steps: VisualizerStep[]): Edge[] { + const edges: Edge[] = []; + const flatNodes = flattenNodes(nodes); + + // Create edges between sequential top-level nodes + for (let i = 0; i < flatNodes.length - 1; i++) { + const source = flatNodes[i]; + const target = flatNodes[i + 1]; + + // Only connect nodes at the same level (not nested) + if (shouldConnectNodes(source, target)) { + edges.push({ + id: `edge_${source.id}_${target.id}`, + source: source.id, + target: target.id, + sourceX: source.x + source.width / 2, + sourceY: source.y + source.height, + targetX: target.x + target.width / 2, + targetY: target.y, + }); + } + } + + return edges; +} + +/** + * Flatten node tree into array + */ +function flattenNodes(nodes: LayoutNode[]): LayoutNode[] { + const result: LayoutNode[] = []; + + function traverse(node: LayoutNode) { + result.push(node); + for (const child of node.children) { + traverse(child); + } + if (node.parallelBranches) { + for (const branch of node.parallelBranches) { + for (const branchNode of branch) { + traverse(branchNode); + } + } + } + } + + for (const node of nodes) { + traverse(node); + } + + return result; +} + +/** + * Determine if two nodes should be connected + */ +function shouldConnectNodes(source: LayoutNode, target: LayoutNode): boolean { + // Connect User → Agent + if (source.type === 'user' && source.data.isTopNode && target.type === 'agent') { + return true; + } + + // Connect Agent → User (bottom) + if (source.type === 'agent' && target.type === 'user' && target.data.isBottomNode) { + return true; + } + + // Connect Agent → Agent (for delegation returns) + if (source.type === 'agent' && target.type === 'agent') { + return true; + } + + return false; +} + +/** + * Calculate total canvas size + */ +function calculateCanvasSize(nodes: LayoutNode[]): { totalWidth: number; totalHeight: number } { + let maxX = 0; + let maxY = 0; + + const flatNodes = flattenNodes(nodes); + + for (const node of flatNodes) { + maxX = Math.max(maxX, node.x + node.width); + maxY = Math.max(maxY, node.y + node.height); + } + + return { + totalWidth: maxX + 100, // Add margin + totalHeight: maxY + 100, + }; +} diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/utils/nodeDetailsHelper.ts b/client/webui/frontend/src/lib/components/activities/FlowChart/utils/nodeDetailsHelper.ts new file mode 100644 index 000000000..6f35c8c33 --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/utils/nodeDetailsHelper.ts @@ -0,0 +1,406 @@ +import type { VisualizerStep } from "@/lib/types"; +import type { LayoutNode } from "./types"; + +/** + * Represents an artifact created by a tool + */ +export interface CreatedArtifact { + filename: string; + version?: number; + mimeType?: string; + description?: string; +} + +/** + * Represents the request and result information for a node + */ +export interface NodeDetails { + nodeType: LayoutNode['type']; + label: string; + description?: string; // NP-4: Node description to display under the name + requestStep?: VisualizerStep; + resultStep?: VisualizerStep; + outputArtifactStep?: VisualizerStep; // For workflow nodes - the WORKFLOW_NODE_EXECUTION_RESULT with output artifact + relatedSteps?: VisualizerStep[]; // For additional context + createdArtifacts?: CreatedArtifact[]; // For tool nodes - artifacts created by this tool +} + +/** + * Find all steps related to a given node and organize them into request/result pairs + */ +export function findNodeDetails( + node: LayoutNode, + allSteps: VisualizerStep[] +): NodeDetails { + const visualizerStepId = node.data.visualizerStepId; + + if (!visualizerStepId) { + return { + nodeType: node.type, + label: node.data.label, + description: node.data.description, + }; + } + + // Find the primary step for this node + const primaryStep = allSteps.find(s => s.id === visualizerStepId); + + if (!primaryStep) { + return { + nodeType: node.type, + label: node.data.label, + description: node.data.description, + }; + } + + switch (node.type) { + case 'user': + return findUserNodeDetails(node, primaryStep, allSteps); + case 'agent': + return findAgentNodeDetails(node, primaryStep, allSteps); + case 'llm': + return findLLMNodeDetails(node, primaryStep, allSteps); + case 'tool': + return findToolNodeDetails(node, primaryStep, allSteps); + case 'switch': + return findSwitchNodeDetails(node, primaryStep, allSteps); + case 'loop': + return findLoopNodeDetails(node, primaryStep, allSteps); + case 'group': + return findWorkflowGroupDetails(node, primaryStep, allSteps); + default: + return { + nodeType: node.type, + label: node.data.label, + description: node.data.description, + requestStep: primaryStep, + }; + } +} + +/** + * Find details for User nodes + */ +function findUserNodeDetails( + node: LayoutNode, + primaryStep: VisualizerStep, + allSteps: VisualizerStep[] +): NodeDetails { + // Top user node: show initial request + if (node.data.isTopNode) { + return { + nodeType: 'user', + label: 'User Input', + requestStep: primaryStep, + }; + } + + // Bottom user node: show final response + if (node.data.isBottomNode) { + // Find the last AGENT_RESPONSE_TEXT at nesting level 0 + const finalResponse = [...allSteps] + .reverse() + .find(s => s.type === 'AGENT_RESPONSE_TEXT' && s.nestingLevel === 0); + + return { + nodeType: 'user', + label: 'Final Output', + resultStep: finalResponse, + }; + } + + return { + nodeType: 'user', + label: node.data.label, + requestStep: primaryStep, + }; +} + +/** + * Find details for Agent nodes + */ +function findAgentNodeDetails( + node: LayoutNode, + primaryStep: VisualizerStep, + allSteps: VisualizerStep[] +): NodeDetails { + const isWorkflowAgent = primaryStep.type === 'WORKFLOW_NODE_EXECUTION_START'; + const workflowNodeData = isWorkflowAgent ? primaryStep.data.workflowNodeExecutionStart : undefined; + const subTaskId = workflowNodeData?.subTaskId; + + // For workflow agents, we need to search in the subTaskId's events + // For regular agents, use the node's owningTaskId + const agentTaskId = subTaskId || node.owningTaskId; + + // Find the request step + let requestStep: VisualizerStep | undefined; + + // First try to find a USER_REQUEST step (exists for root-level agents) + const userRequest = allSteps.find( + s => s.owningTaskId === agentTaskId && s.type === 'USER_REQUEST' + ); + + if (userRequest) { + requestStep = userRequest; + } else if (isWorkflowAgent) { + // For workflow agents, look for the WORKFLOW_AGENT_REQUEST step which contains the actual input + const workflowAgentRequest = allSteps.find( + s => s.owningTaskId === agentTaskId && s.type === 'WORKFLOW_AGENT_REQUEST' + ); + // Fall back to WORKFLOW_NODE_EXECUTION_START if no WORKFLOW_AGENT_REQUEST found + requestStep = workflowAgentRequest || primaryStep; + } else { + // Check if this is a sub-agent created via tool invocation + const toolInvocation = allSteps.find( + s => s.owningTaskId === agentTaskId && s.type === 'AGENT_TOOL_INVOCATION_START' + ); + + if (toolInvocation && toolInvocation.parentTaskId) { + // Try to find the USER_REQUEST from the parent task + const parentUserRequest = allSteps.find( + s => s.owningTaskId === toolInvocation.parentTaskId && s.type === 'USER_REQUEST' + ); + requestStep = parentUserRequest || toolInvocation; + } else { + // Fallback to primaryStep + requestStep = primaryStep; + } + } + + // Find the response (AGENT_RESPONSE_TEXT for this agent's task) + const responseStep = allSteps.find( + s => s.owningTaskId === agentTaskId && s.type === 'AGENT_RESPONSE_TEXT' + ); + + // For workflow agents, find the WORKFLOW_NODE_EXECUTION_RESULT which contains output artifact + let outputArtifactStep: VisualizerStep | undefined; + if (isWorkflowAgent && workflowNodeData?.nodeId) { + // The result step is at the workflow level, not the agent's task level + // Find by matching BOTH nodeId AND owningTaskId to handle parallel workflow executions + const workflowExecutionId = primaryStep.owningTaskId; + outputArtifactStep = allSteps.find( + s => s.type === 'WORKFLOW_NODE_EXECUTION_RESULT' && + s.owningTaskId === workflowExecutionId && + s.data.workflowNodeExecutionResult?.nodeId === workflowNodeData.nodeId + ); + } + + // Find all steps for this agent's task for additional context + const relatedSteps = allSteps.filter(s => s.owningTaskId === agentTaskId); + + return { + nodeType: 'agent' as const, + label: node.data.label, + description: node.data.description, + requestStep, + resultStep: responseStep, + outputArtifactStep, + relatedSteps, + }; +} + +/** + * Find details for LLM nodes + */ +function findLLMNodeDetails( + node: LayoutNode, + primaryStep: VisualizerStep, + allSteps: VisualizerStep[] +): NodeDetails { + // Primary step could be AGENT_LLM_CALL or AGENT_LLM_RESPONSE_TOOL_DECISION (for synthetic LLM nodes) + let requestStep: VisualizerStep | undefined; + let resultStep: VisualizerStep | undefined; + + if (primaryStep.type === 'AGENT_LLM_CALL') { + // Normal case: we have the LLM call step + requestStep = primaryStep; + + const owningTaskId = requestStep.owningTaskId; + const requestIndex = allSteps.indexOf(requestStep); + + // Look for the next LLM response in the same task (either type) + resultStep = allSteps + .slice(requestIndex + 1) + .find(s => + s.owningTaskId === owningTaskId && + (s.type === 'AGENT_LLM_RESPONSE_TO_AGENT' || s.type === 'AGENT_LLM_RESPONSE_TOOL_DECISION') + ); + } else if (primaryStep.type === 'AGENT_LLM_RESPONSE_TOOL_DECISION' || primaryStep.type === 'AGENT_LLM_RESPONSE_TO_AGENT') { + // Synthetic LLM node case: we only have the response step + // Try to find the preceding AGENT_LLM_CALL for this task + const owningTaskId = primaryStep.owningTaskId; + const responseIndex = allSteps.indexOf(primaryStep); + + // Look backwards for the most recent AGENT_LLM_CALL in the same task + for (let i = responseIndex - 1; i >= 0; i--) { + const s = allSteps[i]; + if (s.owningTaskId === owningTaskId && s.type === 'AGENT_LLM_CALL') { + requestStep = s; + break; + } + } + + // The result step is the primary step itself + resultStep = primaryStep; + } + + return { + nodeType: 'llm', + label: node.data.label, + description: node.data.description, + requestStep, + resultStep, + }; +} + +/** + * Find details for Tool nodes + */ +function findToolNodeDetails( + node: LayoutNode, + primaryStep: VisualizerStep, + allSteps: VisualizerStep[] +): NodeDetails { + // Primary step should be AGENT_TOOL_INVOCATION_START + const requestStep = primaryStep.type === 'AGENT_TOOL_INVOCATION_START' ? primaryStep : undefined; + + // Find the result by matching functionCallId + // Check both the step's functionCallId and the data's functionCallId + let resultStep: VisualizerStep | undefined; + + const functionCallId = requestStep?.functionCallId || requestStep?.data.toolInvocationStart?.functionCallId; + + if (functionCallId) { + resultStep = allSteps.find( + s => s.type === 'AGENT_TOOL_EXECUTION_RESULT' && + s.data.toolResult?.functionCallId === functionCallId + ); + } + + // Get created artifacts from node data (populated by layoutEngine) + const createdArtifacts = node.data.createdArtifacts; + + return { + nodeType: 'tool', + label: node.data.label, + description: node.data.description, + requestStep, + resultStep, + createdArtifacts, + }; +} + +/** + * Find details for Switch nodes + */ +function findSwitchNodeDetails( + node: LayoutNode, + primaryStep: VisualizerStep, + allSteps: VisualizerStep[] +): NodeDetails { + // Primary step should be WORKFLOW_NODE_EXECUTION_START with nodeType: switch + const requestStep = primaryStep.type === 'WORKFLOW_NODE_EXECUTION_START' ? primaryStep : undefined; + + // Find the result by matching nodeId + let resultStep: VisualizerStep | undefined; + + if (requestStep?.data.workflowNodeExecutionStart) { + const nodeId = requestStep.data.workflowNodeExecutionStart.nodeId; + const owningTaskId = requestStep.owningTaskId; + + resultStep = allSteps.find( + s => s.type === 'WORKFLOW_NODE_EXECUTION_RESULT' && + s.owningTaskId === owningTaskId && + s.data.workflowNodeExecutionResult?.nodeId === nodeId + ); + } + + return { + nodeType: 'switch', + label: node.data.label, + description: node.data.description, + requestStep, + resultStep, + }; +} + +/** + * Find details for Loop nodes + */ +function findLoopNodeDetails( + node: LayoutNode, + primaryStep: VisualizerStep, + allSteps: VisualizerStep[] +): NodeDetails { + // Primary step should be WORKFLOW_NODE_EXECUTION_START with nodeType: loop + const requestStep = primaryStep.type === 'WORKFLOW_NODE_EXECUTION_START' ? primaryStep : undefined; + + // Find the result by matching nodeId + let resultStep: VisualizerStep | undefined; + + if (requestStep?.data.workflowNodeExecutionStart) { + const nodeId = requestStep.data.workflowNodeExecutionStart.nodeId; + const owningTaskId = requestStep.owningTaskId; + + resultStep = allSteps.find( + s => s.type === 'WORKFLOW_NODE_EXECUTION_RESULT' && + s.owningTaskId === owningTaskId && + s.data.workflowNodeExecutionResult?.nodeId === nodeId + ); + } + + // Find related steps for loop iterations + const relatedSteps = requestStep ? allSteps.filter( + s => s.owningTaskId === requestStep.owningTaskId && + s.type === 'WORKFLOW_NODE_EXECUTION_START' && + s.data.workflowNodeExecutionStart?.parentNodeId === requestStep.data.workflowNodeExecutionStart?.nodeId + ) : undefined; + + return { + nodeType: 'loop', + label: node.data.label, + description: node.data.description, + requestStep, + resultStep, + relatedSteps, + }; +} + +/** + * Find details for Workflow Group nodes + */ +function findWorkflowGroupDetails( + node: LayoutNode, + primaryStep: VisualizerStep, + allSteps: VisualizerStep[] +): NodeDetails { + // Primary step should be WORKFLOW_EXECUTION_START + const requestStep = primaryStep.type === 'WORKFLOW_EXECUTION_START' ? primaryStep : undefined; + + // Find the result by matching executionId + let resultStep: VisualizerStep | undefined; + + if (requestStep?.data.workflowExecutionStart) { + const executionId = requestStep.data.workflowExecutionStart.executionId; + + resultStep = allSteps.find( + s => s.type === 'WORKFLOW_EXECUTION_RESULT' && + s.owningTaskId === executionId + ); + } + + // Find all workflow node steps for context + const relatedSteps = allSteps.filter( + s => s.owningTaskId === requestStep?.data.workflowExecutionStart?.executionId && + (s.type.startsWith('WORKFLOW_NODE_') || s.type === 'WORKFLOW_MAP_PROGRESS') + ); + + return { + nodeType: 'group', + label: node.data.label, + description: node.data.description, + requestStep, + resultStep, + relatedSteps, + }; +} diff --git a/client/webui/frontend/src/lib/components/activities/FlowChart/utils/types.ts b/client/webui/frontend/src/lib/components/activities/FlowChart/utils/types.ts new file mode 100644 index 000000000..aaa87791f --- /dev/null +++ b/client/webui/frontend/src/lib/components/activities/FlowChart/utils/types.ts @@ -0,0 +1,132 @@ +import type { VisualizerStep } from "@/lib/types"; + +/** + * Represents a node in the layout tree structure. + * Nodes can contain children (tools/LLMs/sub-agents) and have calculated positions/dimensions. + */ +export interface LayoutNode { + id: string; + type: 'agent' | 'tool' | 'llm' | 'user' | 'switch' | 'loop' | 'map' | 'group' | 'workflow' | 'parallelBlock'; + data: { + label: string; + visualizerStepId?: string; + description?: string; + status?: string; + variant?: 'default' | 'pill'; + // Switch node fields + condition?: string; + cases?: { condition: string; node: string }[]; + defaultBranch?: string; + selectedBranch?: string; + selectedCaseIndex?: number; + // Join node fields + waitFor?: string[]; + joinStrategy?: string; + joinN?: number; + // Loop node fields + maxIterations?: number; + loopDelay?: string; + currentIteration?: number; + // Map/Fork node fields + iterationIndex?: number; + // Tool node fields - artifacts created by this tool + createdArtifacts?: Array<{ + filename: string; + version?: number; + mimeType?: string; + description?: string; + }>; + // Common fields + isTopNode?: boolean; + isBottomNode?: boolean; + isSkipped?: boolean; + [key: string]: any; + }; + + // Layout properties + x: number; // Absolute X position + y: number; // Absolute Y position + width: number; // Calculated width + height: number; // Calculated height + + // Hierarchy + children: LayoutNode[]; // Sequential children (tools, LLMs, sub-agents) + parallelBranches?: LayoutNode[][]; // For Map/Fork - each array is a parallel branch + + // Context + owningTaskId?: string; + parentTaskId?: string; + functionCallId?: string; +} + +/** + * Represents an edge between two nodes in the visualization. + */ +export interface Edge { + id: string; + source: string; // Node ID + target: string; // Node ID + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + visualizerStepId?: string; + label?: string; + isError?: boolean; + isSelected?: boolean; +} + +/** + * Context for building the layout tree from VisualizerSteps + */ +export interface BuildContext { + steps: VisualizerStep[]; + stepIndex: number; + nodeCounter: number; + + // Map task IDs to their container nodes + taskToNodeMap: Map; + + // Map function call IDs to nodes (for tool results) + functionCallToNodeMap: Map; + + // Current agent node being built + currentAgentNode: LayoutNode | null; + + // Root nodes (top-level user/agent pairs) + rootNodes: LayoutNode[]; + + // Agent name display map + agentNameMap: Record; + + // Map workflow nodeId to Map/Fork node for parallel branch tracking + parallelContainerMap: Map; + + // Track current branch within a parallel container + currentBranchMap: Map; + + // Track if we've created top/bottom user nodes (only one each for entire flow) + hasTopUserNode: boolean; + hasBottomUserNode: boolean; + + // Track parallel peer delegation groups: maps a unique group key to the set of functionCallIds + // Key format: `${owningTaskId}:parallel:${stepId}` where stepId is from the TOOL_DECISION step + parallelPeerGroupMap: Map>; + + // Track the parallelBlock node for each parallel peer group (for adding peer agents to it) + parallelBlockMap: Map; + + // Track sub-workflow -> parent group relationships (for workflow node types) + // Maps subTaskId -> parent workflow group node + subWorkflowParentMap: Map; +} + +/** + * Layout calculation result + */ +export interface LayoutResult { + nodes: LayoutNode[]; + edges: Edge[]; + totalWidth: number; + totalHeight: number; +} diff --git a/client/webui/frontend/src/lib/components/activities/FlowChartPanel.tsx b/client/webui/frontend/src/lib/components/activities/FlowChartPanel.tsx deleted file mode 100644 index c4ae98d11..000000000 --- a/client/webui/frontend/src/lib/components/activities/FlowChartPanel.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { Background, Controls, MarkerType, Panel, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState, useReactFlow } from "@xyflow/react"; -import type { Edge, Node } from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; - -import { PopoverManual } from "@/lib/components/ui"; -import { useChatContext, useTaskContext } from "@/lib/hooks"; -import type { VisualizerStep } from "@/lib/types"; -import { getThemeButtonHtmlStyles } from "@/lib/utils"; - -import { EdgeAnimationService } from "./FlowChart/edgeAnimationService"; -import { GROUP_PADDING_X, GROUP_PADDING_Y, NODE_HEIGHT, NODE_WIDTH } from "./FlowChart/taskToFlowData.helpers"; -import { transformProcessedStepsToTimelineFlow } from "./FlowChart/taskToFlowData"; -import GenericFlowEdge, { type AnimatedEdgeData } from "./FlowChart/customEdges/GenericFlowEdge"; -import GenericAgentNode from "./FlowChart/customNodes/GenericAgentNode"; -import GenericToolNode from "./FlowChart/customNodes/GenericToolNode"; -import LLMNode from "./FlowChart/customNodes/LLMNode"; -import OrchestratorAgentNode from "./FlowChart/customNodes/OrchestratorAgentNode"; -import UserNode from "./FlowChart/customNodes/UserNode"; -import ArtifactNode from "./FlowChart/customNodes/GenericArtifactNode"; -import { VisualizerStepCard } from "./VisualizerStepCard"; - -const nodeTypes = { - genericAgentNode: GenericAgentNode, - userNode: UserNode, - llmNode: LLMNode, - orchestratorNode: OrchestratorAgentNode, - genericToolNode: GenericToolNode, - artifactNode: ArtifactNode, -}; - -const edgeTypes = { - defaultFlowEdge: GenericFlowEdge, -}; - -interface FlowChartPanelProps { - processedSteps: VisualizerStep[]; - isRightPanelVisible?: boolean; - isSidePanelTransitioning?: boolean; -} - -// Stable offset object to prevent unnecessary re-renders -const POPOVER_OFFSET = { x: 16, y: 0 }; - -// Internal component to house the React Flow logic -const FlowRenderer: React.FC = ({ processedSteps, isRightPanelVisible = false, isSidePanelTransitioning = false }) => { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const { fitView } = useReactFlow(); - const { highlightedStepId, setHighlightedStepId } = useTaskContext(); - const { taskIdInSidePanel, agentNameDisplayNameMap } = useChatContext(); - - const prevProcessedStepsRef = useRef([]); - const [hasUserInteracted, setHasUserInteracted] = useState(false); - - // Popover state for edge clicks - const [selectedStep, setSelectedStep] = useState(null); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const popoverAnchorRef = useRef(null); - - // Track selected edge for highlighting - const [selectedEdgeId, setSelectedEdgeId] = useState(null); - - const edgeAnimationServiceRef = useRef(new EdgeAnimationService()); - - const memoizedFlowData = useMemo(() => { - if (!processedSteps || processedSteps.length === 0) { - return { nodes: [], edges: [] }; - } - return transformProcessedStepsToTimelineFlow(processedSteps, agentNameDisplayNameMap); - }, [processedSteps, agentNameDisplayNameMap]); - - // Consolidated edge computation - const computedEdges = useMemo(() => { - if (!memoizedFlowData.edges.length) return []; - - return memoizedFlowData.edges.map(edge => { - const edgeData = edge.data as unknown as AnimatedEdgeData; - - // Determine animation state - let animationState = { isAnimated: false, animationType: "none" }; - if (edgeData?.visualizerStepId) { - const stepIndex = processedSteps.length - 1; - animationState = edgeAnimationServiceRef.current.getEdgeAnimationState(edgeData.visualizerStepId, stepIndex, processedSteps); - } - - // Determine if this edge should be selected - let isSelected = edge.id === selectedEdgeId; - - // If highlightedStepId is set, also select the corresponding edge - if (highlightedStepId && edgeData?.visualizerStepId === highlightedStepId) { - isSelected = true; - } - - return { - ...edge, - animated: animationState.isAnimated, - data: { - ...edgeData, - isAnimated: animationState.isAnimated, - animationType: animationState.animationType, - isSelected, - } as unknown as Record, - }; - }); - }, [memoizedFlowData.edges, processedSteps, selectedEdgeId, highlightedStepId]); - - const updateGroupNodeSizes = useCallback(() => { - setNodes(currentNodes => { - return currentNodes.map(node => { - if (node.type !== "group") return node; - - // Find all child nodes of this group - const childNodes = currentNodes.filter(n => n.parentId === node.id); - if (childNodes.length === 0) return node; - - // Calculate required width and height based on child positions - let maxX = 0; - let maxY = 0; - - childNodes.forEach(child => { - const childRight = child.position.x + NODE_WIDTH; - const childBottom = child.position.y + NODE_HEIGHT; - maxX = Math.max(maxX, childRight); - maxY = Math.max(maxY, childBottom); - }); - - // Add padding - const requiredWidth = maxX + GROUP_PADDING_X; - const requiredHeight = maxY + GROUP_PADDING_Y; - - // Ensure minimum width for indented groups - // Extract indentation level from group ID if possible - let indentationLevel = 0; - const groupIdParts = node.id.split("_"); - if (groupIdParts.length > 2) { - // Try to extract the subflow number which correlates with indentation level - const subflowPart = groupIdParts.find(part => part.startsWith("subflow")); - if (subflowPart) { - const subflowNum = parseInt(subflowPart.replace("subflow", "")); - if (!isNaN(subflowNum)) { - indentationLevel = subflowNum; - } - } - } - - // Add extra space for indented tool nodes - const minRequiredWidth = NODE_WIDTH + 2 * GROUP_PADDING_X + indentationLevel * 50; - const finalRequiredWidth = Math.max(requiredWidth, minRequiredWidth); - - // Update group node style if needed - const currentWidth = parseInt(node.style?.width?.toString().replace("px", "") || "0"); - const currentHeight = parseInt(node.style?.height?.toString().replace("px", "") || "0"); - - if (currentWidth !== finalRequiredWidth || currentHeight !== requiredHeight) { - return { - ...node, - style: { - ...node.style, - width: `${finalRequiredWidth}px`, - height: `${requiredHeight}px`, - }, - }; - } - - return node; - }); - }); - }, [setNodes]); - - useEffect(() => { - setNodes(memoizedFlowData.nodes); - setEdges(computedEdges); - updateGroupNodeSizes(); - }, [memoizedFlowData.nodes, computedEdges, setNodes, setEdges, updateGroupNodeSizes]); - - const findEdgeBySourceAndHandle = useCallback( - (sourceNodeId: string, sourceHandleId?: string): Edge | null => { - return edges.find(edge => edge.source === sourceNodeId && (sourceHandleId ? edge.sourceHandle === sourceHandleId : true)) || null; - }, - [edges] - ); - - const handleEdgeClick = useCallback( - (_event: React.MouseEvent, edge: Edge) => { - setHasUserInteracted(true); - - const stepId = edge.data?.visualizerStepId as string; - if (stepId) { - const step = processedSteps.find(s => s.id === stepId); - if (step) { - setSelectedEdgeId(edge.id); - - if (isRightPanelVisible) { - setHighlightedStepId(stepId); - } else { - setHighlightedStepId(stepId); - setSelectedStep(step); - setIsPopoverOpen(true); - } - } - } - }, - [processedSteps, isRightPanelVisible, setHighlightedStepId] - ); - - const getNodeSourceHandles = useCallback((node: Node): string[] => { - switch (node.type) { - case "userNode": { - const userData = node.data as { isTopNode?: boolean; isBottomNode?: boolean }; - if (userData?.isTopNode) return ["user-bottom-output"]; - if (userData?.isBottomNode) return ["user-top-input"]; - return ["user-right-output"]; - } - case "orchestratorNode": - return ["orch-right-output-tools", "orch-bottom-output"]; - - case "genericAgentNode": - return ["peer-right-output-tools", "peer-bottom-output"]; - - case "llmNode": - return ["llm-bottom-output"]; - - case "genericToolNode": - return [`${node.id}-tool-bottom-output`]; - - default: - return []; - } - }, []); - - const handlePopoverClose = useCallback(() => { - setIsPopoverOpen(false); - setSelectedStep(null); - }, []); - - const handleNodeClick = useCallback( - (_event: React.MouseEvent, node: Node) => { - setHasUserInteracted(true); - - // If clicking on a group container, treat it like clicking on empty space - if (node.type === "group") { - setHighlightedStepId(null); - setSelectedEdgeId(null); - handlePopoverClose(); - return; - } - - const sourceHandles = getNodeSourceHandles(node); - - let targetEdge: Edge | null = null; - for (const handleId of sourceHandles) { - targetEdge = findEdgeBySourceAndHandle(node.id, handleId); - - if (targetEdge) break; - } - - // Special case for bottom UserNode - check for incoming edges instead - if (!targetEdge && node.type === "userNode") { - const userData = node.data as { isBottomNode?: boolean }; - if (userData?.isBottomNode) { - targetEdge = edges.find(edge => edge.target === node.id) || null; - } - } - - if (!targetEdge && node.type === "artifactNode") { - // For artifact nodes, find the tool that created it - targetEdge = edges.find(edge => edge.target === node.id) || null; - } - - if (targetEdge) { - handleEdgeClick(_event, targetEdge); - } - }, - [getNodeSourceHandles, setHighlightedStepId, handlePopoverClose, findEdgeBySourceAndHandle, edges, handleEdgeClick] - ); - - const handleUserMove = useCallback((event: MouseEvent | TouchEvent | null) => { - if (!event?.isTrusted) return; // Ignore synthetic events - setHasUserInteracted(true); - }, []); - - // Reset user interaction state when taskIdInSidePanel changes (new task loaded) - useEffect(() => { - setHasUserInteracted(false); - }, [taskIdInSidePanel]); - - useEffect(() => { - // Only run fitView if the panel is NOT transitioning AND user hasn't interacted - if (!isSidePanelTransitioning && fitView && nodes.length > 0) { - const shouldFitView = prevProcessedStepsRef.current !== processedSteps && hasUserInteracted === false; - if (shouldFitView) { - fitView({ - duration: 200, - padding: 0.1, - maxZoom: 1.2, - }); - - prevProcessedStepsRef.current = processedSteps; - } - } - }, [nodes.length, fitView, processedSteps, isSidePanelTransitioning, hasUserInteracted]); - - // Combined effect for node highlighting and edge selection based on highlightedStepId - useEffect(() => { - // Update node highlighting - setNodes(currentFlowNodes => - currentFlowNodes.map(flowNode => { - const isHighlighted = flowNode.data?.visualizerStepId && flowNode.data.visualizerStepId === highlightedStepId; - - // Find the original node from memoizedFlowData to get its base style - const originalNode = memoizedFlowData.nodes.find(n => n.id === flowNode.id); - const baseStyle = originalNode?.style || {}; - - return { - ...flowNode, - style: { - ...baseStyle, - boxShadow: isHighlighted ? "0px 4px 12px rgba(0, 0, 0, 0.2)" : baseStyle.boxShadow || "none", - transition: "box-shadow 0.2s ease-in-out", - }, - }; - }) - ); - - // Update selected edge - if (highlightedStepId) { - const relatedEdge = computedEdges.find(edge => { - const edgeData = edge.data as unknown as AnimatedEdgeData; - return edgeData?.visualizerStepId === highlightedStepId; - }); - - if (relatedEdge) { - setSelectedEdgeId(relatedEdge.id); - } - } else { - setSelectedEdgeId(null); - } - }, [highlightedStepId, setNodes, memoizedFlowData.nodes, computedEdges]); - - if (!processedSteps || processedSteps.length === 0) { - return
{Object.keys(processedSteps).length > 0 ? "Processing flow data..." : "No steps to display in flow chart."}
; - } - - if (memoizedFlowData.nodes.length === 0 && processedSteps.length > 0) { - return
Generating flow chart...
; - } - - return ( -
- ({ - ...edge, - markerEnd: { type: MarkerType.ArrowClosed, color: "#888" }, - }))} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} - onEdgeClick={handleEdgeClick} - onNodeClick={handleNodeClick} - onPaneClick={() => { - setHighlightedStepId(null); - setSelectedEdgeId(null); - handlePopoverClose(); - }} - onMoveStart={handleUserMove} - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - fitViewOptions={{ padding: 0.1 }} - className={"bg-gray-50 dark:bg-gray-900 [&>button]:dark:bg-gray-700"} - proOptions={{ hideAttribution: true }} - nodesDraggable={false} - elementsSelectable={false} - nodesConnectable={false} - minZoom={0.2} - > - - - -
- - - - {/* Edge Information Popover */} - - {selectedStep && } - -
- ); -}; - -const FlowChartPanel: React.FC = props => { - return ( - - - - ); -}; - -export { FlowChartPanel }; diff --git a/client/webui/frontend/src/lib/components/activities/VisualizerStepCard.tsx b/client/webui/frontend/src/lib/components/activities/VisualizerStepCard.tsx index 636ce4c07..3cee8a538 100644 --- a/client/webui/frontend/src/lib/components/activities/VisualizerStepCard.tsx +++ b/client/webui/frontend/src/lib/components/activities/VisualizerStepCard.tsx @@ -1,10 +1,24 @@ -import React from "react"; +import { useState, type FC, type ReactNode, type MouseEvent } from "react"; -import { CheckCircle, FileText, HardDrive, Link, MessageSquare, Share2, Terminal, User, XCircle, Zap, ExternalLink } from "lucide-react"; +import { CheckCircle, ExternalLink, FileText, GitCommit, GitMerge, HardDrive, Link, List, MessageSquare, Share2, Split, Terminal, User, Workflow, XCircle, Zap } from "lucide-react"; import { JSONViewer, MarkdownHTMLConverter } from "@/lib/components"; import { useChatContext } from "@/lib/hooks"; -import type { ArtifactNotificationData, LLMCallData, LLMResponseToAgentData, ToolDecisionData, ToolInvocationStartData, ToolResultData, VisualizerStep } from "@/lib/types"; +import { ImageSearchGrid } from "@/lib/components/research"; +import type { + ArtifactNotificationData, + LLMCallData, + LLMResponseToAgentData, + ToolDecisionData, + ToolInvocationStartData, + ToolResultData, + VisualizerStep, + WorkflowExecutionResultData, + WorkflowExecutionStartData, + WorkflowNodeExecutionResultData, + WorkflowNodeExecutionStartData, +} from "@/lib/types"; +import { isString } from "@/lib/utils"; interface VisualizerStepCardProps { step: VisualizerStep; @@ -13,7 +27,7 @@ interface VisualizerStepCardProps { variant?: "list" | "popover"; } -const VisualizerStepCard: React.FC = ({ step, isHighlighted, onClick, variant = "list" }) => { +const VisualizerStepCard: FC = ({ step, isHighlighted, onClick, variant = "list" }) => { const { artifacts, setPreviewArtifact, setActiveSidePanelTab, setIsSidePanelCollapsed, navigateArtifactVersion } = useChatContext(); const getStepIcon = () => { @@ -42,6 +56,18 @@ const VisualizerStepCard: React.FC = ({ step, isHighlig return ; case "AGENT_ARTIFACT_NOTIFICATION": return ; + case "WORKFLOW_EXECUTION_START": + case "WORKFLOW_EXECUTION_RESULT": + return ; + case "WORKFLOW_NODE_EXECUTION_START": + if (step.data.workflowNodeExecutionStart?.nodeType === "map") return ; + if (step.data.workflowNodeExecutionStart?.nodeType === "fork") return ; + if (step.data.workflowNodeExecutionStart?.nodeType === "switch") return ; + return ; + case "WORKFLOW_NODE_EXECUTION_RESULT": + return ; + case "WORKFLOW_MAP_PROGRESS": + return ; default: return ; } @@ -67,10 +93,10 @@ const VisualizerStepCard: React.FC = ({ step, isHighlig
); - const LLMResponseToAgentDetails: React.FC<{ data: LLMResponseToAgentData }> = ({ data }) => { - const [expanded, setExpanded] = React.useState(false); + const LLMResponseToAgentDetails: FC<{ data: LLMResponseToAgentData }> = ({ data }) => { + const [expanded, setExpanded] = useState(false); - const toggleExpand = (e: React.MouseEvent) => { + const toggleExpand = (e: MouseEvent) => { e.stopPropagation(); setExpanded(!expanded); }; @@ -146,21 +172,73 @@ const VisualizerStepCard: React.FC = ({ step, isHighlig
); - const renderToolResultData = (data: ToolResultData) => ( -
-

- Tool: {data.toolName} -

-

- Result: -

-
- {typeof data.resultData === "object" ? :
{String(data.resultData)}
} + /** + * Renders result data as either a JSON viewer (for objects) or a preformatted text block (for primitives). + * Abstracts the common pattern of displaying tool result data. + */ + const renderResultData = (resultData: unknown): ReactNode => { + if (typeof resultData === "object") { + // Cast is safe here as JSONViewer handles null and object types + return [0]["data"]} />; + } + return
{String(resultData)}
; + }; + + const renderToolResultData = (data: ToolResultData) => { + // Check if this is a web search result with images + let parsedResult = null; + let hasImages = false; + + try { + // Try to parse the result if it's a string + if (isString(data.resultData)) { + parsedResult = JSON.parse(data.resultData); + } else if (typeof data.resultData === "object") { + parsedResult = data.resultData; + } + + // Check if the result has an images array (from web search tools) + if (parsedResult?.result) { + const innerResult = isString(parsedResult.result) ? JSON.parse(parsedResult.result) : parsedResult.result; + + if (innerResult?.images && Array.isArray(innerResult.images) && innerResult.images.length > 0) { + hasImages = true; + } + } + } catch { + // Not JSON or parsing failed, will display as normal + } + + return ( +
+

+ Tool: {data.toolName} +

+ + {hasImages && parsedResult?.result ? ( + <> +

+ Image Results: +

+ +
+ Show full result data +
{renderResultData(data.resultData)}
+
+ + ) : ( + <> +

+ Result: +

+
{renderResultData(data.resultData)}
+ + )}
-
- ); + ); + }; const renderArtifactNotificationData = (data: ArtifactNotificationData) => { - const handleViewFile = async (e: React.MouseEvent) => { + const handleViewFile = async (e: MouseEvent) => { e.stopPropagation(); // Find the artifact by filename @@ -213,6 +291,102 @@ const VisualizerStepCard: React.FC = ({ step, isHighlig ); }; + const renderWorkflowNodeStartData = (data: WorkflowNodeExecutionStartData) => ( +
+
+ {data.nodeType} Node + {(data.iterationIndex !== undefined && data.iterationIndex !== null && typeof data.iterationIndex === 'number') && Iter #{data.iterationIndex}} +
+ + {data.condition && ( +
+

Condition:

+ {data.condition} +
+ )} + {data.trueBranch && ( +

+ True Branch: {data.trueBranch} +

+ )} + {data.falseBranch && ( +

+ False Branch: {data.falseBranch} +

+ )} +
+ ); + + const renderWorkflowNodeResultData = (data: WorkflowNodeExecutionResultData) => ( +
+

+ Status: {data.status} +

+ {data.metadata?.condition && ( +
+

Condition:

+ {data.metadata.condition} +
+ )} + {data.metadata?.condition_result !== undefined && ( +

+ Condition Result: {data.metadata.condition_result ? "True" : "False"} +

+ )} + {data.outputArtifactRef && ( +

+ Output: {data.outputArtifactRef.name} (v{data.outputArtifactRef.version}) +

+ )} + {data.errorMessage && ( +

+ Error: {data.errorMessage} +

+ )} +
+ ); + + const renderWorkflowExecutionStartData = (data: WorkflowExecutionStartData) => ( +
+

+ Workflow: {data.workflowName} +

+ {data.workflowInput && ( +
+

+ Input: +

+
+ +
+
+ )} +
+ ); + + const renderWorkflowExecutionResultData = (data: WorkflowExecutionResultData) => ( +
+

+ Status: {data.status} +

+ {data.workflowOutput && ( +
+

+ Output: +

+
+ +
+
+ )} + {data.errorMessage && ( +

+ Error: {data.errorMessage} +

+ )} +
+ ); + // Calculate indentation based on nesting level - only apply in list variant const indentationStyle = variant === "list" && step.nestingLevel && step.nestingLevel > 0 @@ -302,6 +476,10 @@ const VisualizerStepCard: React.FC = ({ step, isHighlig {step.data.toolInvocationStart && renderToolInvocationStartData(step.data.toolInvocationStart)} {step.data.toolResult && renderToolResultData(step.data.toolResult)} {step.data.artifactNotification && renderArtifactNotificationData(step.data.artifactNotification)} + {step.data.workflowExecutionStart && renderWorkflowExecutionStartData(step.data.workflowExecutionStart)} + {step.data.workflowNodeExecutionStart && renderWorkflowNodeStartData(step.data.workflowNodeExecutionStart)} + {step.data.workflowNodeExecutionResult && renderWorkflowNodeResultData(step.data.workflowNodeExecutionResult)} + {step.data.workflowExecutionResult && renderWorkflowExecutionResultData(step.data.workflowExecutionResult)}
); }; diff --git a/client/webui/frontend/src/lib/components/activities/index.ts b/client/webui/frontend/src/lib/components/activities/index.ts index f17ec3b96..af201c74f 100644 --- a/client/webui/frontend/src/lib/components/activities/index.ts +++ b/client/webui/frontend/src/lib/components/activities/index.ts @@ -1,4 +1,4 @@ export * from "./FlowChartDetails"; -export * from "./FlowChartPanel"; +export * from "./FlowChart"; export * from "./VisualizerStepCard"; export * from "./taskVisualizerProcessor"; diff --git a/client/webui/frontend/src/lib/components/activities/taskVisualizerProcessor.ts b/client/webui/frontend/src/lib/components/activities/taskVisualizerProcessor.ts index 859b01212..5a5a11c1b 100644 --- a/client/webui/frontend/src/lib/components/activities/taskVisualizerProcessor.ts +++ b/client/webui/frontend/src/lib/components/activities/taskVisualizerProcessor.ts @@ -25,6 +25,19 @@ import type { VisualizedTask, } from "@/lib/types"; +/** + * Checks if an artifact is an intermediate web content artifact from deep research. + * These are temporary files that should not be shown in the workflow visualization. + * + * @param artifactName The name of the artifact to check. + * @returns True if the artifact is an intermediate web content artifact. + */ +const isIntermediateWebContentArtifact = (artifactName: string | undefined): boolean => { + if (!artifactName) return false; + // Skip web_content_ artifacts (temporary files from deep research) + return artifactName.startsWith("web_content_"); +}; + /** * Helper function to get parentTaskId from a TaskFE object. * It first checks the direct `parentTaskId` field. If not present, @@ -57,9 +70,21 @@ const getEventTimestamp = (event: A2AEventSSEPayload): string => { * @param allMonitoredTasks A record of all monitored tasks. * @param taskNestingLevels A map to store the nesting level of each task ID. * @param currentLevel The current nesting level for currentTaskId. + * @param visitedTaskIds A set of task IDs that have already been visited to prevent cycles/duplication. * @returns An array of A2AEventSSEPayload objects from the task and its descendants. */ -const collectAllDescendantEvents = (currentTaskId: string, allMonitoredTasks: Record, taskNestingLevels: Map, currentLevel: number): A2AEventSSEPayload[] => { +const collectAllDescendantEvents = ( + currentTaskId: string, + allMonitoredTasks: Record, + taskNestingLevels: Map, + currentLevel: number, + visitedTaskIds: Set = new Set() +): A2AEventSSEPayload[] => { + if (visitedTaskIds.has(currentTaskId)) { + return []; + } + visitedTaskIds.add(currentTaskId); + const task = allMonitoredTasks[currentTaskId]; if (!task) { console.warn(`[collectAllDescendantEvents] Task not found in allMonitoredTasks: ${currentTaskId}`); @@ -78,7 +103,7 @@ const collectAllDescendantEvents = (currentTaskId: string, allMonitoredTasks: Re const childsParentId = getParentTaskIdFromTaskObject(potentialChildTask); if (childsParentId === currentTaskId) { - events = events.concat(collectAllDescendantEvents(potentialChildTask.taskId, allMonitoredTasks, taskNestingLevels, currentLevel + 1)); + events = events.concat(collectAllDescendantEvents(potentialChildTask.taskId, allMonitoredTasks, taskNestingLevels, currentLevel + 1, visitedTaskIds)); } } return events; @@ -210,7 +235,8 @@ export const processTaskForVisualization = ( parentTaskObject.taskId, allMonitoredTasks, taskNestingLevels, - 0 // Root task is at level 0 + 0, // Root task is at level 0 + new Set() // visitedTaskIds ); if (combinedEvents.length === 0) { @@ -239,6 +265,7 @@ export const processTaskForVisualization = ( const subTaskToFunctionCallIdMap = new Map(); const functionCallIdToDelegationInfoMap = new Map(); const activeFunctionCallIdByTask = new Map(); + const processedWorkflowEvents = new Set(); const flushAggregatedTextStep = (currentEventOwningTaskId?: string) => { if (currentAggregatedText.trim() && aggregatedTextSourceAgent && aggregatedTextTimestamp) { @@ -257,6 +284,9 @@ export const processTaskForVisualization = ( const nestingLevelForFlush = taskNestingLevels.get(owningTaskIdForFlush) ?? 0; const functionCallIdForStep = subTaskToFunctionCallIdMap.get(owningTaskIdForFlush) || activeFunctionCallIdByTask.get(owningTaskIdForFlush); + const taskForFlush = allMonitoredTasks[owningTaskIdForFlush]; + const parentTaskIdForFlush = getParentTaskIdFromTaskObject(taskForFlush); + visualizerSteps.push({ id: `vstep-agenttext-${visualizerSteps.length}-${aggregatedRawEventIds[0] || "unknown"}`, type: "AGENT_RESPONSE_TEXT", @@ -269,6 +299,7 @@ export const processTaskForVisualization = ( isSubTaskStep: nestingLevelForFlush > 0, nestingLevel: nestingLevelForFlush, owningTaskId: owningTaskIdForFlush, + parentTaskId: parentTaskIdForFlush || undefined, functionCallId: functionCallIdForStep, }); lastFlushedAgentResponseText = textToFlush; @@ -288,6 +319,9 @@ export const processTaskForVisualization = ( const currentEventOwningTaskId = event.task_id || parentTaskObject.taskId; const currentEventNestingLevel = taskNestingLevels.get(currentEventOwningTaskId) ?? 0; + const currentTask = allMonitoredTasks[currentEventOwningTaskId]; + const parentTaskId = getParentTaskIdFromTaskObject(currentTask); + // Determine agent name let eventAgentName = event.source_entity || "UnknownAgent"; if (payload?.params?.message?.metadata?.agent_name) { @@ -316,8 +350,10 @@ export const processTaskForVisualization = ( // Handle sub-task creation requests to establish the mapping early if (event.direction === "request" && currentEventNestingLevel > 0) { - const metadata = payload.params?.metadata as any; - const functionCallId = metadata?.function_call_id; + // Note: metadata can be at params level (for tool delegation) or message level (for workflow agent calls) + const paramsMetadata = payload.params?.metadata as any; + const messageMetadata = payload.params?.message?.metadata as any; + const functionCallId = paramsMetadata?.function_call_id || messageMetadata?.function_call_id; const subTaskId = event.task_id; if (subTaskId && functionCallId) { @@ -326,6 +362,95 @@ export const processTaskForVisualization = ( // It doesn't create a visual step itself, so we return. return; } + + // Check if this is a workflow agent request (has workflow_name in message metadata) + const workflowName = messageMetadata?.workflow_name; + const nodeId = messageMetadata?.node_id; + if (workflowName && nodeId) { + // This is a workflow agent invocation - create a WORKFLOW_AGENT_REQUEST step + const params = payload.params as any; + let inputText: string | undefined; + let instruction: string | undefined; + let inputArtifactRef: { name: string; version?: number; uri?: string; mimeType?: string } | undefined; + let inputSchema: Record | undefined; + let outputSchema: Record | undefined; + let suggestedOutputFilename: string | undefined; + + if (params?.message?.parts) { + // Extract structured_invocation_request data part + const structuredInvocationPart = params.message.parts.find((p: any) => + p.kind === "data" && p.data?.type === "structured_invocation_request" + ); + if (structuredInvocationPart) { + const invocationData = structuredInvocationPart.data; + inputSchema = invocationData.input_schema; + outputSchema = invocationData.output_schema; + suggestedOutputFilename = invocationData.suggested_output_filename; + } + + // Extract text parts (skip the reminder text) + // The first non-reminder text part is the instruction if present + const textParts = params.message.parts.filter((p: any) => + p.kind === "text" && p.text && !p.text.includes("REMINDER:") + ); + if (textParts.length > 0) { + // First text part is the instruction + instruction = textParts[0].text; + } + + // Extract file parts (artifact references) - this is the structured input + const fileParts = params.message.parts.filter((p: any) => + p.kind === "file" && p.file + ); + if (fileParts.length > 0) { + const file = fileParts[0].file; + inputArtifactRef = { + name: file.name || "input", + version: file.version, + uri: file.uri, + mimeType: file.mimeType, + }; + } + + // If no file part but text parts exist after instruction, that's the inputText + // (for simple text schemas where input is sent as text, not artifact) + if (!inputArtifactRef && textParts.length > 1) { + inputText = textParts[1].text; + } else if (!inputArtifactRef && textParts.length === 1 && !instruction) { + // Single text part that's not instruction - it's the input + inputText = textParts[0].text; + } + } + + const stepData = { + id: `vstep-wfagentreq-${visualizerSteps.length}-${eventId}`, + type: "WORKFLOW_AGENT_REQUEST" as const, + timestamp: eventTimestamp, + title: `Workflow Request to ${event.target_entity || eventAgentName}`, + source: "Workflow", + target: event.target_entity || eventAgentName, + data: { + workflowAgentRequest: { + agentName: event.target_entity || eventAgentName || "Unknown", + nodeId, + workflowName, + inputText, + inputArtifactRef, + instruction, + inputSchema, + outputSchema, + suggestedOutputFilename, + }, + }, + rawEventIds: [eventId], + isSubTaskStep: true, + nestingLevel: currentEventNestingLevel, + owningTaskId: currentEventOwningTaskId, + parentTaskId: parentTaskId || undefined, + }; + visualizerSteps.push(stepData); + return; + } } // Skip rendering of cancel requests as not enough information is present yet to link to original task @@ -340,8 +465,22 @@ export const processTaskForVisualization = ( let userText = "User request"; if (params?.message?.parts) { const textParts = params.message.parts.filter((p: any) => p.kind === "text" && p.text); - if (textParts.length > 0) { - userText = textParts[textParts.length - 1].text; + // Filter out gateway timestamp parts (they appear like "Request received by gateway at: 2025-12-19T22:46:16.994017+00:00") + // The gateway prepends this as the first part, so we can skip parts that match this pattern + const gatewayTimestampPattern = /^Request received by gateway at:/; + const filteredParts = textParts.filter( + (p: any) => !gatewayTimestampPattern.test(p.text.trim()) + ); + if (filteredParts.length > 0) { + // Join remaining text parts + userText = filteredParts.map((p: any) => p.text).join("\n"); + } else if (textParts.length > 0) { + // Fallback: if all parts were filtered, use the last part but strip the gateway prefix + const lastPart = textParts[textParts.length - 1].text; + // Try to extract text after the timestamp line + const lines = lastPart.split('\n'); + const nonGatewayLines = lines.filter((line: string) => !gatewayTimestampPattern.test(line.trim())); + userText = nonGatewayLines.length > 0 ? nonGatewayLines.join('\n') : lastPart; } } visualizerSteps.push({ @@ -367,9 +506,11 @@ export const processTaskForVisualization = ( const messageMetadata = statusMessage?.metadata as any; let statusUpdateAgentName: string; - const isForwardedMessage = !!messageMetadata?.forwarded_from_peer; + // Check both message metadata and result metadata for forwarding flag + const isForwardedMessage = !!messageMetadata?.forwarded_from_peer || !!result.metadata?.forwarded_from_peer; + if (isForwardedMessage) { - statusUpdateAgentName = messageMetadata.forwarded_from_peer; + statusUpdateAgentName = messageMetadata?.forwarded_from_peer || result.metadata?.forwarded_from_peer; } else if (result.metadata?.agent_name) { statusUpdateAgentName = result.metadata.agent_name as string; } else if (messageMetadata?.agent_name) { @@ -397,6 +538,171 @@ export const processTaskForVisualization = ( const signalType = signalData?.type as string; switch (signalType) { + case "workflow_execution_start": { + const dedupKey = `start:${signalData.execution_id}`; + + if (processedWorkflowEvents.has(dedupKey)) { + break; + } + processedWorkflowEvents.add(dedupKey); + + const stepId = `vstep-wfstart-${visualizerSteps.length}-${eventId}`; + visualizerSteps.push({ + id: stepId, + type: "WORKFLOW_EXECUTION_START", + timestamp: eventTimestamp, + title: `Workflow Started: ${signalData.workflow_name}`, + source: "System", + target: "Workflow", + data: { + workflowExecutionStart: { + workflowName: signalData.workflow_name, + executionId: signalData.execution_id, + inputArtifactRef: signalData.input_artifact_ref, + workflowInput: signalData.workflow_input, + }, + }, + rawEventIds: [eventId], + isSubTaskStep: currentEventNestingLevel > 0, + nestingLevel: currentEventNestingLevel, + owningTaskId: currentEventOwningTaskId, + parentTaskId: parentTaskId || undefined, + functionCallId: functionCallIdForStep, + }); + break; + } + case "workflow_node_execution_start": { + const dedupKey = `node_start:${currentEventOwningTaskId}:${signalData.sub_task_id || signalData.node_id}:${signalData.iteration_index || 0}`; + if (processedWorkflowEvents.has(dedupKey)) break; + processedWorkflowEvents.add(dedupKey); + + visualizerSteps.push({ + id: `vstep-wfnode-start-${visualizerSteps.length}-${eventId}`, + type: "WORKFLOW_NODE_EXECUTION_START", + timestamp: eventTimestamp, + title: `Node Started: ${signalData.node_id} (${signalData.node_type})`, + source: "Workflow", + target: signalData.agent_name || signalData.node_id, + data: { + workflowNodeExecutionStart: { + nodeId: signalData.node_id, + nodeType: signalData.node_type, + agentName: signalData.agent_name, + inputArtifactRef: signalData.input_artifact_ref, + iterationIndex: signalData.iteration_index, + // Conditional node fields + condition: signalData.condition, + trueBranch: signalData.true_branch, + falseBranch: signalData.false_branch, + trueBranchLabel: signalData.true_branch_label, + falseBranchLabel: signalData.false_branch_label, + // Switch node fields + cases: signalData.cases, + defaultBranch: signalData.default_branch, + // Join node fields + waitFor: signalData.wait_for, + joinStrategy: signalData.join_strategy, + joinN: signalData.join_n, + // Loop node fields + maxIterations: signalData.max_iterations, + loopDelay: signalData.loop_delay, + // Common fields + subTaskId: signalData.sub_task_id, + parentNodeId: signalData.parent_node_id, + // Parallel grouping + parallelGroupId: signalData.parallel_group_id, + }, + }, + rawEventIds: [eventId], + isSubTaskStep: currentEventNestingLevel > 0, + nestingLevel: currentEventNestingLevel, + owningTaskId: currentEventOwningTaskId, + functionCallId: functionCallIdForStep, + }); + break; + } + case "workflow_node_execution_result": { + const dedupKey = `node_result:${currentEventOwningTaskId}:${signalData.node_id}:${signalData.status}`; + if (processedWorkflowEvents.has(dedupKey)) break; + processedWorkflowEvents.add(dedupKey); + + visualizerSteps.push({ + id: `vstep-wfnode-result-${visualizerSteps.length}-${eventId}`, + type: "WORKFLOW_NODE_EXECUTION_RESULT", + timestamp: eventTimestamp, + title: `Node Completed: ${signalData.node_id} (${signalData.status})`, + source: signalData.node_id, + target: "Workflow", + data: { + workflowNodeExecutionResult: { + nodeId: signalData.node_id, + status: signalData.status, + outputArtifactRef: signalData.output_artifact_ref, + errorMessage: signalData.error_message, + metadata: signalData.metadata, + conditionResult: signalData.metadata?.condition_result, + }, + }, + rawEventIds: [eventId], + isSubTaskStep: currentEventNestingLevel > 0, + nestingLevel: currentEventNestingLevel, + owningTaskId: currentEventOwningTaskId, + functionCallId: functionCallIdForStep, + }); + break; + } + case "workflow_map_progress": { + visualizerSteps.push({ + id: `vstep-wfmap-${visualizerSteps.length}-${eventId}`, + type: "WORKFLOW_MAP_PROGRESS", + timestamp: eventTimestamp, + title: `Map Progress: ${signalData.node_id} (${signalData.completed_items}/${signalData.total_items})`, + source: signalData.node_id, + target: "Workflow", + data: { + workflowMapProgress: { + nodeId: signalData.node_id, + totalItems: signalData.total_items, + completedItems: signalData.completed_items, + status: signalData.status, + }, + }, + rawEventIds: [eventId], + isSubTaskStep: currentEventNestingLevel > 0, + nestingLevel: currentEventNestingLevel, + owningTaskId: currentEventOwningTaskId, + functionCallId: functionCallIdForStep, + }); + break; + } + case "workflow_execution_result": { + const dedupKey = `result:${currentEventOwningTaskId}:${signalData.status}`; + if (processedWorkflowEvents.has(dedupKey)) break; + processedWorkflowEvents.add(dedupKey); + + visualizerSteps.push({ + id: `vstep-wfresult-${visualizerSteps.length}-${eventId}`, + type: "WORKFLOW_EXECUTION_RESULT", + timestamp: eventTimestamp, + title: `Workflow Finished: ${signalData.status}`, + source: "Workflow", + target: "User", + data: { + workflowExecutionResult: { + status: signalData.status, + outputArtifactRef: signalData.output_artifact_ref, + errorMessage: signalData.error_message, + workflowOutput: signalData.workflow_output, + }, + }, + rawEventIds: [eventId], + isSubTaskStep: currentEventNestingLevel > 0, + nestingLevel: currentEventNestingLevel, + owningTaskId: currentEventOwningTaskId, + functionCallId: functionCallIdForStep, + }); + break; + } case "agent_progress_update": { visualizerSteps.push({ id: `vstep-progress-${visualizerSteps.length}-${eventId}`, @@ -418,13 +724,86 @@ export const processTaskForVisualization = ( const llmData = signalData.request as any; let promptText = "System-initiated LLM call"; if (llmData?.contents && Array.isArray(llmData.contents) && llmData.contents.length > 0) { - // Find the last user message in the history to use as the prompt preview. - const lastUserContent = [...llmData.contents].reverse().find((c: any) => c.role === "user"); - if (lastUserContent && lastUserContent.parts) { - promptText = lastUserContent.parts - .map((p: any) => p.text || "") // Handle cases where text might be null/undefined - .join("\n") - .trim(); + // Get the last message in the conversation to understand what triggered this LLM call + const lastContent = llmData.contents[llmData.contents.length - 1]; + const lastRole = lastContent?.role; + + // Check if this LLM call is following tool results + // Tool results can be: role="tool", role="function", or parts with function_response + const toolResultContents = llmData.contents.filter((c: any) => + c.role === "tool" || c.role === "function" || + (c.parts && c.parts.some((p: any) => p.function_response)) + ); + const hasToolResults = toolResultContents.length > 0; + + if (hasToolResults && lastRole !== "user") { + // This is a follow-up LLM call after tool execution + // Count only the tool results from the CURRENT turn, not the entire conversation history + // Find the index of the last "model" role message (the LLM's response that called tools) + let lastModelIndex = -1; + for (let i = llmData.contents.length - 1; i >= 0; i--) { + if (llmData.contents[i].role === "model") { + lastModelIndex = i; + break; + } + } + + // Count function_response parts that come AFTER the last model message + // Also collect summaries of the tool results + let actualToolResultCount = 0; + const toolResultSummaries: string[] = []; + const startIndex = lastModelIndex + 1; + for (let i = startIndex; i < llmData.contents.length; i++) { + const content = llmData.contents[i]; + if (content.parts && Array.isArray(content.parts)) { + for (const part of content.parts) { + if (part.function_response) { + actualToolResultCount++; + const toolName = part.function_response.name || "unknown"; + const response = part.function_response.response; + // Create a brief summary of the response + let summary: string; + if (typeof response === "string") { + summary = response.substring(0, 200); + } else if (typeof response === "object" && response !== null) { + const jsonStr = JSON.stringify(response); + summary = jsonStr.substring(0, 200); + } else { + summary = String(response).substring(0, 200); + } + if (summary.length === 200) summary += "..."; + toolResultSummaries.push(`- ${toolName}: ${summary}`); + } + } + } + } + // If no function_response parts found after last model, fall back to 0 (shouldn't happen) + const toolResultCount = actualToolResultCount > 0 ? actualToolResultCount : 0; + + // Find the LAST user message (which is the current turn's request) + const lastUserContent = [...llmData.contents].reverse().find((c: any) => c.role === "user"); + const userPromptFull = lastUserContent?.parts + ?.map((p: any) => p.text || "") + .join(" ") + .trim() || ""; + const userPromptSnippet = userPromptFull.substring(0, 5000); + + // Build prompt text with tool result summaries + let toolResultsSection = ""; + if (toolResultSummaries.length > 0) { + toolResultsSection = "\n\nTool Results:\n" + toolResultSummaries.join("\n"); + } + + promptText = `[Following ${toolResultCount} tool result(s)]\nOriginal request: ${userPromptSnippet || "N/A"}${userPromptFull.length > 5000 ? "..." : ""}${toolResultsSection}`; + } else { + // Regular LLM call - find the last user message + const lastUserContent = [...llmData.contents].reverse().find((c: any) => c.role === "user"); + if (lastUserContent && lastUserContent.parts) { + promptText = lastUserContent.parts + .map((p: any) => p.text || "") // Handle cases where text might be null/undefined + .join("\n") + .trim(); + } } } const llmCallData: LLMCallData = { @@ -459,7 +838,7 @@ export const processTaskForVisualization = ( } const llmResponseData = signalData.data as any; - const contentParts = llmResponseData.content?.parts as any[]; + const contentParts = llmResponseData?.content?.parts as any[]; const functionCallParts = contentParts?.filter(p => p.function_call); if (functionCallParts && functionCallParts.length > 0) { @@ -471,7 +850,7 @@ export const processTaskForVisualization = ( functionCallId: p.function_call.id, toolName: p.function_call.name, toolArguments: p.function_call.args || {}, - isPeerDelegation: p.function_call.name?.startsWith("peer_"), + isPeerDelegation: p.function_call.name?.startsWith("peer_") || p.function_call.name?.startsWith("workflow_"), })); const toolDecisionData: ToolDecisionData = { decisions, isParallel: decisions.length > 1 }; @@ -479,7 +858,13 @@ export const processTaskForVisualization = ( const claimedSubTaskIds = new Set(); decisions.forEach(decision => { if (decision.isPeerDelegation) { - const peerAgentActualName = decision.toolName.substring(5); + let peerAgentActualName = decision.toolName; + if (decision.toolName.startsWith("peer_")) { + peerAgentActualName = decision.toolName.substring(5); + } else if (decision.toolName.startsWith("workflow_")) { + peerAgentActualName = decision.toolName.substring(9); + } + for (const stId in allMonitoredTasks) { const candSubTask = allMonitoredTasks[stId]; if (claimedSubTaskIds.has(candSubTask.taskId)) continue; @@ -507,7 +892,7 @@ export const processTaskForVisualization = ( } }); - const toolDecisionStep: VisualizerStep = { + const toolDecisionStep: VisualizerStep = { id: `vstep-tooldecision-${visualizerSteps.length}-${eventId}`, type: "AGENT_LLM_RESPONSE_TOOL_DECISION", timestamp: eventTimestamp, @@ -554,6 +939,7 @@ export const processTaskForVisualization = ( .join("\n") || ""; const llmResponseToAgentData: LLMResponseToAgentData = { responsePreview: llmResponseText.substring(0, 200) + (llmResponseText.length > 200 ? "..." : ""), + response: llmResponseText, // Store full response isFinalResponse: llmResponseData?.partial === false, }; visualizerSteps.push({ @@ -578,8 +964,12 @@ export const processTaskForVisualization = ( functionCallId: signalData.function_call_id, toolName: signalData.tool_name, toolArguments: signalData.tool_args, - isPeerInvocation: signalData.tool_name?.startsWith("peer_"), + isPeerInvocation: signalData.tool_name?.startsWith("peer_") || signalData.tool_name?.startsWith("workflow_"), + parallelGroupId: signalData.parallel_group_id, }; + + const delegationInfo = functionCallIdToDelegationInfoMap.get(signalData.function_call_id); + visualizerSteps.push({ id: `vstep-toolinvokestart-${visualizerSteps.length}-${eventId}`, type: "AGENT_TOOL_INVOCATION_START", @@ -589,6 +979,7 @@ export const processTaskForVisualization = ( target: invocationData.toolName, data: { toolInvocationStart: invocationData }, rawEventIds: [eventId], + delegationInfo: delegationInfo ? [delegationInfo] : undefined, isSubTaskStep: currentEventNestingLevel > 0, nestingLevel: currentEventNestingLevel, owningTaskId: currentEventOwningTaskId, @@ -604,12 +995,21 @@ export const processTaskForVisualization = ( const duration = new Date(eventTimestamp).getTime() - new Date(openToolCallForPerf.timestamp).getTime(); const invokingAgentMetrics = report.agents[openToolCallForPerf.invokingAgentInstanceId]; if (invokingAgentMetrics) { + let peerAgentName: string | undefined; + if (openToolCallForPerf.isPeer) { + if (openToolCallForPerf.toolName.startsWith("peer_")) { + peerAgentName = openToolCallForPerf.toolName.substring(5); + } else if (openToolCallForPerf.toolName.startsWith("workflow_")) { + peerAgentName = openToolCallForPerf.toolName.substring(9); + } + } + const toolCallPerf: ToolCallPerformance = { toolName: openToolCallForPerf.toolName, durationMs: duration, isPeer: openToolCallForPerf.isPeer, timestamp: openToolCallForPerf.timestamp, - peerAgentName: openToolCallForPerf.isPeer ? openToolCallForPerf.toolName.substring(5) : undefined, + peerAgentName: peerAgentName, subTaskId: openToolCallForPerf.subTaskId, parallelBlockId: openToolCallForPerf.parallelBlockId, }; @@ -623,7 +1023,7 @@ export const processTaskForVisualization = ( toolName: signalData.tool_name, functionCallId: functionCallId, resultData: signalData.result_data, - isPeerResponse: signalData.tool_name?.startsWith("peer_"), + isPeerResponse: signalData.tool_name?.startsWith("peer_") || signalData.tool_name?.startsWith("workflow_"), }; visualizerSteps.push({ id: `vstep-toolresult-${visualizerSteps.length}-${eventId}`, @@ -643,7 +1043,8 @@ export const processTaskForVisualization = ( // Check if this is _notify_artifact_save and we have a pending artifact if (signalData.tool_name === "_notify_artifact_save" && functionCallId) { const pendingArtifact = pendingArtifacts.get(functionCallId); - if (pendingArtifact) { + // Skip intermediate web content artifacts from deep research + if (pendingArtifact && !isIntermediateWebContentArtifact(pendingArtifact.filename)) { const artifactNotification: ArtifactNotificationData = { artifactName: pendingArtifact.filename, version: pendingArtifact.version, @@ -664,8 +1065,8 @@ export const processTaskForVisualization = ( owningTaskId: pendingArtifact.taskId, functionCallId: functionCallId, }); - pendingArtifacts.delete(functionCallId); } + pendingArtifacts.delete(functionCallId); } break; } @@ -676,13 +1077,20 @@ export const processTaskForVisualization = ( // Handle new artifact_saved event type flushAggregatedTextStep(currentEventOwningTaskId); + const artifactFilename = signalData.filename || "Unnamed Artifact"; + + // Skip intermediate web content artifacts from deep research + if (isIntermediateWebContentArtifact(artifactFilename)) { + break; + } + // Check if this has a function_call_id (from fenced blocks with _notify_artifact_save) const isSyntheticToolCall = signalData.function_call_id && signalData.function_call_id.startsWith("host-notify-"); if (isSyntheticToolCall) { // Queue this artifact - will be created when we see the _notify_artifact_save tool result pendingArtifacts.set(signalData.function_call_id, { - filename: signalData.filename || "Unnamed Artifact", + filename: artifactFilename, version: signalData.version, description: signalData.description, mimeType: signalData.mime_type, @@ -695,7 +1103,7 @@ export const processTaskForVisualization = ( } else { // Regular tool call - create node immediately const artifactNotification: ArtifactNotificationData = { - artifactName: signalData.filename || "Unnamed Artifact", + artifactName: artifactFilename, version: signalData.version, description: signalData.description, mimeType: signalData.mime_type, @@ -717,6 +1125,9 @@ export const processTaskForVisualization = ( } break; } + default: + console.log(`Received unknown data part type: ${signalType}`, signalData); + break; } } else if (part.kind === "text" && part.text) { if (aggregatedTextSourceAgent && aggregatedTextSourceAgent !== statusUpdateAgentName) { @@ -740,6 +1151,13 @@ export const processTaskForVisualization = ( if (event.direction === "artifact_update" && payload?.result?.artifact) { flushAggregatedTextStep(currentEventOwningTaskId); const artifactData = payload.result.artifact as Artifact; + const artifactName = artifactData.name || "Unnamed Artifact"; + + // Skip intermediate web content artifacts from deep research + if (isIntermediateWebContentArtifact(artifactName)) { + return; + } + const artifactAgentName = (artifactData.metadata?.agent_name as string) || event.source_entity || "Agent"; let mimeType: string | undefined = undefined; if (artifactData.parts && artifactData.parts.length > 0) { @@ -751,7 +1169,7 @@ export const processTaskForVisualization = ( } } const artifactNotification: ArtifactNotificationData = { - artifactName: artifactData.name || "Unnamed Artifact", + artifactName: artifactName, version: typeof artifactData.metadata?.version === "number" ? artifactData.metadata.version : undefined, description: artifactData.description || undefined, mimeType, @@ -783,7 +1201,7 @@ export const processTaskForVisualization = ( const finalState = result.status.state as string; const responseAgentName = result.metadata?.agent_name || result.status?.message?.metadata?.agent_name || event.source_entity || "Agent"; - if (["completed", "failed", "canceled"].includes(finalState) && currentEventNestingLevel == 0) { + if (["completed", "failed", "canceled"].includes(finalState)) { const stepType: VisualizerStepType = finalState === "completed" ? "TASK_COMPLETED" : "TASK_FAILED"; const title = `${responseAgentName}: Task ${finalState.charAt(0).toUpperCase() + finalState.slice(1)}`; let dataPayload: any = {}; diff --git a/client/webui/frontend/src/lib/components/agents/AgentDisplayCard.tsx b/client/webui/frontend/src/lib/components/agents/AgentDisplayCard.tsx index 6fb60aba3..db9b31dcf 100644 --- a/client/webui/frontend/src/lib/components/agents/AgentDisplayCard.tsx +++ b/client/webui/frontend/src/lib/components/agents/AgentDisplayCard.tsx @@ -1,9 +1,10 @@ import React from "react"; import type { ReactNode } from "react"; -import { GitMerge, Info, Book, Link, Paperclip, Box, Wrench, Key, Bot, Code } from "lucide-react"; +import { GitMerge, Info, Book, Link, Paperclip, Box, Wrench, Key, Bot, Code, Workflow } from "lucide-react"; import type { AgentCardInfo, AgentSkill } from "@/lib/types"; +import { isWorkflowAgent, getWorkflowNodeCount } from "@/lib/utils/agentUtils"; interface DetailItemProps { label: string; @@ -34,6 +35,9 @@ const DetailItem: React.FC = ({ label, value, icon, fullWidthVa }; export const AgentDisplayCard: React.FC = ({ agent, isExpanded, onToggleExpand }) => { + const isWorkflow = isWorkflowAgent(agent); + const nodeCount = isWorkflow ? getWorkflowNodeCount(agent) : 0; + const renderCapabilities = (capabilities?: { [key: string]: unknown } | null) => { if (!capabilities || Object.keys(capabilities).length === 0) return N/A; return ( @@ -90,8 +94,12 @@ export const AgentDisplayCard: React.FC = ({ agent, isExp
-
- +
+ {isWorkflow ? ( + + ) : ( + + )}

{agent.displayName || agent.name} @@ -146,6 +154,12 @@ export const AgentDisplayCard: React.FC = ({ agent, isExp } fullWidthValue /> } fullWidthValue /> } fullWidthValue /> + {isWorkflow && nodeCount > 0 && ( +
+

Workflow Information

+ } /> +
+ )}
diff --git a/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx b/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx index 8509eb886..d2c639f16 100644 --- a/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx +++ b/client/webui/frontend/src/lib/components/chat/ChatInputArea.tsx @@ -801,13 +801,19 @@ export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?: /> {/* Buttons */} -
+
Agent:
- { + handleAgentSelection(agentName); + }} + disabled={isResponding || agents.length === 0} + > @@ -827,7 +833,7 @@ export const ChatInputArea: React.FC<{ agents: AgentCardInfo[]; scrollToBottom?: {sttEnabled && settings.speechToText && } {isResponding && !isCancelling ? ( - diff --git a/client/webui/frontend/src/lib/components/chat/ChatMessage.tsx b/client/webui/frontend/src/lib/components/chat/ChatMessage.tsx index f7ce1b09a..eef014074 100644 --- a/client/webui/frontend/src/lib/components/chat/ChatMessage.tsx +++ b/client/webui/frontend/src/lib/components/chat/ChatMessage.tsx @@ -1,14 +1,21 @@ -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; import type { ReactNode } from "react"; import { AlertCircle, ThumbsDown, ThumbsUp } from "lucide-react"; -import { ChatBubble, ChatBubbleMessage, MarkdownHTMLConverter, MessageBanner } from "@/lib/components"; +import { ChatBubble, ChatBubbleMessage, MarkdownHTMLConverter, MarkdownWrapper, MessageBanner } from "@/lib/components"; import { Button } from "@/lib/components/ui"; import { ViewWorkflowButton } from "@/lib/components/ui/ViewWorkflowButton"; import { useChatContext } from "@/lib/hooks"; -import type { ArtifactPart, FileAttachment, FilePart, MessageFE, TextPart } from "@/lib/types"; +import type { ArtifactInfo, ArtifactPart, DataPart, FileAttachment, FilePart, MessageFE, RAGSearchResult, TextPart } from "@/lib/types"; import type { ChatContextValue } from "@/lib/contexts"; +import { InlineResearchProgress, type ResearchProgressData } from "@/lib/components/research/InlineResearchProgress"; +import { DeepResearchReportContent } from "@/lib/components/research/DeepResearchReportContent"; +import { Sources } from "@/lib/components/web/Sources"; +import { ImageSearchGrid } from "@/lib/components/research"; +import { isDeepResearchReportFilename } from "@/lib/utils/deepResearchUtils"; +import { TextWithCitations } from "./Citation"; +import { parseCitations } from "@/lib/utils/citations"; import { ArtifactMessage, FileMessage } from "./file"; import { FeedbackModal } from "./FeedbackModal"; @@ -28,7 +35,10 @@ const MessageActions: React.FC<{ showWorkflowButton: boolean; showFeedbackActions: boolean; handleViewWorkflowClick: () => void; -}> = ({ message, showWorkflowButton, showFeedbackActions, handleViewWorkflowClick }) => { + sourcesElement?: React.ReactNode; + /** Optional text content override */ + textContentOverride?: string; +}> = ({ message, showWorkflowButton, showFeedbackActions, handleViewWorkflowClick, sourcesElement, textContentOverride }) => { const { configCollectFeedback, submittedFeedback, handleFeedbackSubmit, addNotification } = useChatContext(); const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false); const [feedbackType, setFeedbackType] = useState<"up" | "down" | null>(null); @@ -55,7 +65,7 @@ const MessageActions: React.FC<{ const shouldShowFeedback = showFeedbackActions && configCollectFeedback; - if (!showWorkflowButton && !shouldShowFeedback) { + if (!showWorkflowButton && !shouldShowFeedback && !sourcesElement) { return null; } @@ -74,7 +84,8 @@ const MessageActions: React.FC<{
)} - + + {sourcesElement &&
{sourcesElement}
}

{feedbackType && } @@ -82,9 +93,40 @@ const MessageActions: React.FC<{ ); }; -const MessageContent = React.memo<{ message: MessageFE }>(({ message }) => { +/** + * Transform technical workflow error messages into user-friendly ones + */ +const getUserFriendlyErrorMessage = (technicalMessage: string): string => { + // Pattern: "Workflow failed: Node 'X' failed: Node execution error: No FilePart found in message for structured schema" + if (technicalMessage.includes("No FilePart found in message for structured schema")) { + return "This workflow requires a file to be uploaded. Please attach a file and try again."; + } + + // Pattern: "Workflow failed: Node 'X' failed: Node execution error: ..." + if (technicalMessage.includes("Workflow failed:") && technicalMessage.includes("Node execution error:")) { + const match = technicalMessage.match(/Node execution error:\s*(.+)$/); + if (match) { + return `The workflow encountered an error: ${match[1]}`; + } + } + + // Pattern: "Workflow failed: ..." + if (technicalMessage.startsWith("Workflow failed:")) { + return technicalMessage.replace(/^Workflow failed:\s*/, "The workflow encountered an error: "); + } + + // Pattern: Generic task failure + if (technicalMessage.toLowerCase().includes("task failed")) { + return "The request could not be completed. Please try again or contact support."; + } + + // Default: return the original message if no pattern matches + return technicalMessage; +}; + +const MessageContent = React.memo<{ message: MessageFE; isStreaming?: boolean }>(({ message, isStreaming }) => { const [renderError, setRenderError] = useState(null); - const { sessionId } = useChatContext(); + const { sessionId, ragData, openSidePanelTab, setTaskIdInSidePanel } = useChatContext(); // Extract text content from message parts const textContent = @@ -96,25 +138,64 @@ const MessageContent = React.memo<{ message: MessageFE }>(({ message }) => { // Trim text for user messages to prevent trailing whitespace issues const displayText = message.isUser ? textContent.trim() : textContent; - const renderContent = () => { - if (message.isError) { - return ( -
- - {displayText} -
- ); + // Parse citations from text and match to RAG sources + // Aggregate sources from ALL RAG entries for this task, not just the last one. + // When there are multiple web searches (multiple tool calls), each creates a separate RAG entry. + const taskRagData = useMemo(() => { + if (!message.taskId || !ragData) return undefined; + const matches = ragData.filter(r => r.taskId === message.taskId); + if (matches.length === 0) return undefined; + + // If only one entry, return it directly + if (matches.length === 1) return matches[0]; + + // Aggregate all sources from all matching RAG entries + // Use the last entry as the base (for query, title, etc.) but combine all sources + const lastEntry = matches[matches.length - 1]; + const allSources = matches.flatMap(r => r.sources || []); + + // Deduplicate sources by citationId (keep the first occurrence) + const seenCitationIds = new Set(); + const uniqueSources = allSources.filter(source => { + const citationId = source.citationId; + if (!citationId || seenCitationIds.has(citationId)) { + return false; + } + seenCitationIds.add(citationId); + return true; + }); + + return { + ...lastEntry, + sources: uniqueSources, + }; + }, [message.taskId, ragData]); + + const citations = useMemo(() => { + if (message.isUser) return []; + return parseCitations(displayText, taskRagData); + }, [displayText, taskRagData, message.isUser]); + + const handleCitationClick = () => { + // Open RAG panel when citation is clicked + if (message.taskId) { + setTaskIdInSidePanel(message.taskId); + openSidePanelTab("rag"); } + }; + + // Extract embedded content and compute modified text at component level + const embeddedContent = useMemo(() => extractEmbeddedContent(displayText), [displayText]); - const embeddedContent = extractEmbeddedContent(displayText); + const { modifiedText, contentElements } = useMemo(() => { if (embeddedContent.length === 0) { - return {displayText}; + return { modifiedText: displayText, contentElements: [] }; } - let modifiedText = displayText; - const contentElements: ReactNode[] = []; + let modText = displayText; + const elements: ReactNode[] = []; embeddedContent.forEach((item: ExtractedContent, index: number) => { - modifiedText = modifiedText.replace(item.originalMatch, ""); + modText = modText.replace(item.originalMatch, ""); if (item.type === "file") { const fileAttachment: FileAttachment = { @@ -122,7 +203,7 @@ const MessageContent = React.memo<{ message: MessageFE }>(({ message }) => { content: item.content, mime_type: item.mimeType, }; - contentElements.push( + elements.push(
downloadFile(fileAttachment, sessionId)} isEmbedded={true} />
@@ -130,19 +211,57 @@ const MessageContent = React.memo<{ message: MessageFE }>(({ message }) => { } else if (!RENDER_TYPES_WITH_RAW_CONTENT.includes(item.type)) { const finalContent = decodeBase64Content(item.content); if (finalContent) { - contentElements.push( + elements.push(
- +
); } } }); + return { modifiedText: modText, contentElements: elements }; + }, [embeddedContent, displayText, sessionId, setRenderError, taskRagData]); + + // Parse citations from modified text + const modifiedCitations = useMemo(() => { + if (message.isUser) return []; + return parseCitations(modifiedText, taskRagData); + }, [modifiedText, taskRagData, message.isUser]); + + const renderContent = () => { + if (message.isError) { + const friendlyErrorMessage = getUserFriendlyErrorMessage(displayText); + return ( +
+ + {friendlyErrorMessage} +
+ ); + } + + if (embeddedContent.length === 0) { + // Use MarkdownWrapper for streaming (smooth animation), TextWithCitations otherwise (citation support) + if (isStreaming) { + return ; + } + // Render text with citations if any exist + if (citations.length > 0) { + return ; + } + return {displayText}; + } + return (
{renderError && } - {modifiedText} + {isStreaming ? ( + + ) : modifiedCitations.length > 0 ? ( + + ) : ( + {modifiedText} + )} {contentElements}
); @@ -177,8 +296,40 @@ const getUploadedFiles = (message: MessageFE) => { return null; }; -const getChatBubble = (message: MessageFE, chatContext: ChatContextValue, isLastWithTaskId?: boolean) => { - const { openSidePanelTab, setTaskIdInSidePanel } = chatContext; +interface DeepResearchReportInfo { + artifact: ArtifactInfo; + sessionId: string; + ragData?: RAGSearchResult; +} + +// Component to render deep research report with TTS support +const DeepResearchReportBubble: React.FC<{ + deepResearchReportInfo: DeepResearchReportInfo; + message: MessageFE; + onContentLoaded?: (content: string) => void; +}> = ({ deepResearchReportInfo, message, onContentLoaded }) => { + return ( + + + + + + + + ); +}; + +const getChatBubble = ( + message: MessageFE, + chatContext: ChatContextValue, + isLastWithTaskId?: boolean, + isStreaming?: boolean, + sourcesElement?: React.ReactNode, + deepResearchReportInfo?: DeepResearchReportInfo, + onReportContentLoaded?: (content: string) => void, + reportContentOverride?: string +): React.ReactNode => { + const { openSidePanelTab, setTaskIdInSidePanel, ragData } = chatContext; if (message.isStatusBubble) { return null; @@ -188,6 +339,43 @@ const getChatBubble = (message: MessageFE, chatContext: ChatContextValue, isLast return ; } + // Check for deep research progress data + const progressPart = message.parts?.find(p => p.kind === "data") as DataPart | undefined; + const hasDeepResearchProgress = progressPart?.data && (progressPart.data as { type?: string }).type === "deep_research_progress"; + + // Show progress block at the top if we have progress data and research is not complete + if (hasDeepResearchProgress && !message.isComplete) { + const data = progressPart!.data as unknown as ResearchProgressData; + const taskRagData = ragData?.filter(r => r.taskId === message.taskId); + const hasOtherContent = message.parts?.some(p => (p.kind === "text" && (p as TextPart).text.trim()) || p.kind === "artifact" || p.kind === "file"); + + // Always show progress block for active research (before completion) + const progressBlock = ( +
+ +
+ ); + + // If this is progress-only (no other content), just return the progress block + if (!hasOtherContent) { + return progressBlock; + } + + // If there's other content, show progress block first, then the rest + // Create a new message without the progress data part to avoid infinite recursion + const messageWithoutProgress = { + ...message, + parts: message.parts?.filter(p => p.kind !== "data"), + }; + + return ( + <> + {progressBlock} + {getChatBubble(messageWithoutProgress, chatContext, isLastWithTaskId)} + + ); + } + // Group contiguous parts to handle interleaving of text and files const groupedParts: (TextPart | FilePart | ArtifactPart)[] = []; let currentTextGroup = ""; @@ -216,15 +404,28 @@ const getChatBubble = (message: MessageFE, chatContext: ChatContextValue, isLast const showWorkflowButton = !message.isUser && message.isComplete && !!message.taskId && !!isLastWithTaskId; const showFeedbackActions = !message.isUser && message.isComplete && !!message.taskId && !!isLastWithTaskId; + // Debug logging for error messages + if (message.isError) { + console.log('[ChatMessage] Error message debug:', { + isUser: message.isUser, + isComplete: message.isComplete, + taskId: message.taskId, + isLastWithTaskId: isLastWithTaskId, + showWorkflowButton, + showFeedbackActions, + parts: message.parts, + }); + } + const handleViewWorkflowClick = () => { if (message.taskId) { setTaskIdInSidePanel(message.taskId); - openSidePanelTab("workflow"); + openSidePanelTab("activity"); } }; // Helper function to render artifact/file parts - const renderArtifactOrFilePart = (part: ArtifactPart | FilePart, index: number) => { + const renderArtifactOrFilePart = (part: ArtifactPart | FilePart, index: number, isStreamingPart?: boolean) => { // Create unique key for expansion state using taskId (or messageId) + filename const uniqueKey = message.taskId ? `${message.taskId}-${part.kind === "file" ? (part as FilePart).file.name : (part as ArtifactPart).name}` @@ -244,17 +445,17 @@ const getChatBubble = (message: MessageFE, chatContext: ChatContextValue, isLast } else if ("uri" in fileInfo && fileInfo.uri) { attachment.uri = fileInfo.uri; } - return ; + return ; } if (part.kind === "artifact") { const artifactPart = part as ArtifactPart; switch (artifactPart.status) { case "completed": - return ; + return ; case "in-progress": - return ; + return ; case "failed": - return ; + return ; default: return null; } @@ -271,6 +472,7 @@ const getChatBubble = (message: MessageFE, chatContext: ChatContextValue, isLast {/* Render parts in their original order to preserve interleaving */} {groupedParts.map((part, index) => { const isLastPart = index === lastPartIndex; + const shouldStream = isStreaming && isLastPart; if (part.kind === "text") { // Skip rendering empty or whitespace-only text parts @@ -280,7 +482,13 @@ const getChatBubble = (message: MessageFE, chatContext: ChatContextValue, isLast if (isLastPart && (showWorkflowButton || showFeedbackActions)) { return (
- +
); } @@ -290,22 +498,41 @@ const getChatBubble = (message: MessageFE, chatContext: ChatContextValue, isLast return ( - + {/* Show actions on the last part if it's text */} - {isLastPart && } + {isLastPart && ( + + )} ); } else if (part.kind === "artifact" || part.kind === "file") { - return renderArtifactOrFilePart(part, index); + return renderArtifactOrFilePart(part, index, shouldStream); } return null; })} + {/* Show deep research report content inline (without References and Methodology sections) */} + {deepResearchReportInfo && } + {/* Show actions after artifacts if the last part is an artifact */} {lastPartKind === "artifact" || lastPartKind === "file" ? (
- +
) : null} @@ -318,15 +545,227 @@ const getChatBubble = (message: MessageFE, chatContext: ChatContextValue, isLast
); }; -export const ChatMessage: React.FC<{ message: MessageFE; isLastWithTaskId?: boolean }> = ({ message, isLastWithTaskId }) => { +export const ChatMessage: React.FC<{ message: MessageFE; isLastWithTaskId?: boolean; isStreaming?: boolean }> = ({ message, isLastWithTaskId, isStreaming }) => { const chatContext = useChatContext(); + const { ragData, openSidePanelTab, setTaskIdInSidePanel, artifacts, sessionId } = chatContext; + + // State to track deep research report content for message actions functionality + const [reportContent, setReportContent] = useState(null); + + // Get RAG metadata for this task + const taskRagData = useMemo(() => { + if (!message?.taskId || !ragData) return undefined; + return ragData.filter(r => r.taskId === message.taskId); + }, [message?.taskId, ragData]); + + // Find deep research report artifact in the message + const deepResearchReportArtifact = useMemo(() => { + if (!message) return null; + + // Check if this is a completed deep research message + const hasProgressPart = message.parts?.some(p => { + if (p.kind === "data") { + const data = (p as DataPart).data as unknown as ResearchProgressData; + return data?.type === "deep_research_progress"; + } + return false; + }); + + const hasRagSources = taskRagData && taskRagData.length > 0 && taskRagData.some(r => r.sources && r.sources.length > 0); + const hasDeepResearchRagData = taskRagData?.some(r => r.searchType === "deep_research"); + const isDeepResearchComplete = message.isComplete && (hasProgressPart || hasDeepResearchRagData) && hasRagSources; + + if (!isDeepResearchComplete || !isLastWithTaskId) return null; + + // Look for artifact parts in the message that match deep research report pattern + const artifactParts = message.parts?.filter(p => p.kind === "artifact") as ArtifactPart[] | undefined; + + // First priority: Find the report artifact from this message's artifact parts + // This ensures we get the correct report for this specific task + if (artifactParts && artifactParts.length > 0) { + for (const part of artifactParts) { + if (part.status === "completed" && isDeepResearchReportFilename(part.name)) { + const fullArtifact = artifacts.find(a => a.filename === part.name); + if (fullArtifact) { + return fullArtifact; + } + } + } + } + + // Second priority: Use artifact filename from RAG metadata + // The backend stores the artifact filename in the RAG metadata for this purpose + if (taskRagData && taskRagData.length > 0) { + // Get the last RAG data entry which should have the artifact filename + const lastRagData = taskRagData[taskRagData.length - 1]; + const artifactFilenameFromRag = lastRagData.metadata?.artifactFilename as string | undefined; + if (artifactFilenameFromRag) { + const matchedArtifact = artifacts.find(a => a.filename === artifactFilenameFromRag); + if (matchedArtifact) { + return matchedArtifact; + } + } + } + // Only use global artifacts list if there's exactly one report artifact + // This handles edge cases but avoids showing the wrong report when there are multiple + const allReportArtifacts = artifacts.filter(a => isDeepResearchReportFilename(a.filename)); + if (allReportArtifacts.length === 1) { + return allReportArtifacts[0]; + } + + // If there are multiple report artifacts and we couldn't find one, + // don't show any inline report to avoid showing the wrong one + return null; + }, [message, isLastWithTaskId, artifacts, taskRagData, sessionId]); + + // Get the last RAG data entry for this task (for citations in report) + const lastTaskRagData = useMemo(() => { + if (!taskRagData || taskRagData.length === 0) return undefined; + return taskRagData[taskRagData.length - 1]; + }, [taskRagData]); + + // Early return after all hooks if (!message) { return null; } + + // Check if this is a completed deep research message + // Check both for progress data part (during session) and ragData search_type (after refresh) + const hasProgressPart = message.parts?.some(p => { + if (p.kind === "data") { + const data = (p as DataPart).data as unknown as ResearchProgressData; + return data?.type === "deep_research_progress"; + } + return false; + }); + + const hasRagSources = taskRagData && taskRagData.length > 0 && taskRagData.some(r => r.sources && r.sources.length > 0); + + // Check if ragData indicates deep research + const hasDeepResearchRagData = taskRagData?.some(r => r.searchType === "deep_research"); + + const isDeepResearchComplete = message.isComplete && (hasProgressPart || hasDeepResearchRagData) && hasRagSources; + + // Check if this is a completed web search message (has web_search sources but not deep research) + const isWebSearchComplete = message.isComplete && !isDeepResearchComplete && hasRagSources && taskRagData?.some(r => r.searchType === "web_search"); + + // Handler for sources click (works for both deep research and web search) + const handleSourcesClick = () => { + if (message.taskId) { + setTaskIdInSidePanel(message.taskId); + openSidePanelTab("rag"); + } + }; + return ( <> - {getChatBubble(message, chatContext, isLastWithTaskId)} + {/* Show progress block at the top for completed deep research - only for the last message with this taskId */} + {isDeepResearchComplete && + hasRagSources && + isLastWithTaskId && + (() => { + // Filter to only show fetched sources (not snippets) + const allSources = taskRagData.flatMap(r => r.sources); + const fetchedSources = allSources.filter(source => { + const wasFetched = source.metadata?.fetched === true || source.metadata?.fetch_status === "success" || (source.contentPreview && source.contentPreview.includes("[Full Content Fetched]")); + return wasFetched; + }); + + return ( +
+ +
+ ); + })()} + {getChatBubble( + message, + chatContext, + isLastWithTaskId, + isStreaming, + // Show sources element for both deep research and web search (in message actions area) + !message.isUser && (isDeepResearchComplete || isWebSearchComplete) && hasRagSources + ? (() => { + const allSources = taskRagData.flatMap(r => r.sources); + + // For deep research: filter to only show fetched sources (not snippets) + // For web search: show all sources including images (images with source links will be shown) + const sourcesToShow = isDeepResearchComplete + ? allSources.filter(source => { + const sourceType = source.sourceType || "web"; + // For images in deep research: include if they have a source link + if (sourceType === "image") { + return source.sourceUrl || source.metadata?.link; + } + const wasFetched = source.metadata?.fetched === true || source.metadata?.fetch_status === "success" || (source.contentPreview && source.contentPreview.includes("[Full Content Fetched]")); + return wasFetched; + }) + : allSources.filter(source => { + const sourceType = source.sourceType || "web"; + // For images in web search: include if they have a source link + if (sourceType === "image") { + return source.sourceUrl || source.metadata?.link; + } + return true; + }); + + // Only render if we have sources + if (sourcesToShow.length === 0) return null; + + return ; + })() + : undefined, + // Pass deep research report info if available + isDeepResearchComplete && isLastWithTaskId && deepResearchReportArtifact && sessionId ? { artifact: deepResearchReportArtifact, sessionId, ragData: lastTaskRagData } : undefined, + // Callback to capture report content for TTS/copy + setReportContent, + // Pass report content to MessageActions for TTS/copy + reportContent || undefined + )} + + {/* Render images separately at the end for web search */} + {!message.isUser && + isWebSearchComplete && + hasRagSources && + (() => { + const allSources = taskRagData.flatMap(r => r.sources); + const imageResults = allSources + .filter(source => { + const sourceType = source.sourceType || "web"; + return sourceType === "image" && source.metadata?.imageUrl; + }) + .map(source => ({ + imageUrl: source.metadata!.imageUrl, + title: source.metadata?.title || source.filename, + link: source.sourceUrl || source.metadata?.link || source.metadata!.imageUrl, + })); + + if (imageResults.length > 0) { + return ( +
+ +
+ ); + } + return null; + })()} + {getUploadedFiles(message)} ); diff --git a/client/webui/frontend/src/lib/components/chat/ChatSidePanel.tsx b/client/webui/frontend/src/lib/components/chat/ChatSidePanel.tsx index 6be0255c5..f356b172c 100644 --- a/client/webui/frontend/src/lib/components/chat/ChatSidePanel.tsx +++ b/client/webui/frontend/src/lib/components/chat/ChatSidePanel.tsx @@ -1,14 +1,16 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; -import { PanelRightIcon, FileText, Network, RefreshCw } from "lucide-react"; +import { PanelRightIcon, FileText, Network, RefreshCw, Link2 } from "lucide-react"; import { Button, Tabs, TabsList, TabsTrigger, TabsContent } from "@/lib/components/ui"; import { useTaskContext, useChatContext } from "@/lib/hooks"; import { FlowChartPanel, processTaskForVisualization } from "@/lib/components/activities"; import type { VisualizedTask } from "@/lib/types"; +import { hasSourcesWithUrls } from "@/lib/utils"; import { ArtifactPanel } from "./artifact/ArtifactPanel"; import { FlowChartDetails } from "../activities/FlowChartDetails"; +import { RAGInfoPanel } from "./rag/RAGInfoPanel"; interface ChatSidePanelProps { onCollapsedToggle: (isSidePanelCollapsed: boolean) => void; @@ -18,7 +20,7 @@ interface ChatSidePanelProps { } export const ChatSidePanel: React.FC = ({ onCollapsedToggle, isSidePanelCollapsed, setIsSidePanelCollapsed, isSidePanelTransitioning }) => { - const { activeSidePanelTab, setActiveSidePanelTab, setPreviewArtifact, taskIdInSidePanel } = useChatContext(); + const { activeSidePanelTab, setActiveSidePanelTab, setPreviewArtifact, taskIdInSidePanel, ragData, ragEnabled } = useChatContext(); const { isReconnecting, isTaskMonitorConnecting, isTaskMonitorConnected, monitoredTasks, connectTaskMonitorStream, loadTaskFromBackend } = useTaskContext(); const [visualizedTask, setVisualizedTask] = useState(null); const [isLoadingTask, setIsLoadingTask] = useState(false); @@ -26,7 +28,10 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle, // Track which task IDs we've already attempted to load to prevent duplicate loads const loadAttemptedRef = React.useRef>(new Set()); - // Process task data for visualization when the selected workflow task ID changes + // Check if there are any sources in the current session (web sources or deep research sources) + const hasSourcesInSession = useMemo(() => hasSourcesWithUrls(ragData), [ragData]); + + // Process task data for visualization when the selected activity task ID changes // or when monitoredTasks is updated with new data useEffect(() => { if (!taskIdInSidePanel) { @@ -96,11 +101,11 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle, } }, [taskIdInSidePanel]); - // Helper function to determine what to display in the workflow panel - const getWorkflowPanelContent = () => { + // Helper function to determine what to display in the activity panel + const getActivityPanelContent = () => { if (isLoadingTask) { return { - message: "Loading workflow data...", + message: "Loading activity data...", showButton: false, }; } @@ -130,7 +135,7 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle, if (!visualizedTask) { return { - message: "No workflow data available for the selected task", + message: "No activity data available for the selected task", showButton: false, }; } @@ -144,7 +149,7 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle, onCollapsedToggle(newCollapsed); }; - const handleTabClick = (tab: "files" | "workflow") => { + const handleTabClick = (tab: "files" | "activity" | "rag") => { if (tab === "files") { setPreviewArtifact(null); } @@ -152,7 +157,7 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle, setActiveSidePanelTab(tab); }; - const handleIconClick = (tab: "files" | "workflow") => { + const handleIconClick = (tab: "files" | "activity" | "rag") => { if (isSidePanelCollapsed) { setIsSidePanelCollapsed(false); onCollapsedToggle?.(false); @@ -175,9 +180,15 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle, - + + {hasSourcesInSession && ( + + )}
); } @@ -186,29 +197,39 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle, return (
- handleTabClick(value as "files" | "workflow")} className="flex h-full flex-col"> -
- - + setPreviewArtifact(null)} > - - Files + + Files - - Workflow + + Activity + {hasSourcesInSession && ( + + + Sources + + )}
@@ -218,10 +239,10 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle,
- +
{(() => { - const emptyStateContent = getWorkflowPanelContent(); + const emptyStateContent = getActivityPanelContent(); if (!emptyStateContent && visualizedTask) { return ( @@ -236,7 +257,7 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle,
-
Workflow
+
Activity
{emptyStateContent?.message}
{emptyStateContent?.showButton && (
@@ -256,6 +277,14 @@ export const ChatSidePanel: React.FC = ({ onCollapsedToggle, })()}
+ + {hasSourcesInSession && ( + +
+ r.taskId === taskIdInSidePanel) : ragData} enabled={ragEnabled} /> +
+
+ )}
diff --git a/client/webui/frontend/src/lib/components/chat/Citation.tsx b/client/webui/frontend/src/lib/components/chat/Citation.tsx new file mode 100644 index 000000000..1a7191120 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/Citation.tsx @@ -0,0 +1,552 @@ +/** + * Citation component for displaying clickable source citations + */ +import React, { useState, useMemo, useRef, useCallback, type ReactNode } from "react"; +import DOMPurify from "dompurify"; +import { marked } from "marked"; +import parse, { type HTMLReactParserOptions, type DOMNode, Element, Text as DomText } from "html-react-parser"; +import type { Citation as CitationType } from "@/lib/utils/citations"; +import { getCitationTooltip, INDIVIDUAL_CITATION_PATTERN } from "@/lib/utils/citations"; +import { MarkdownHTMLConverter } from "@/lib/components"; +import { getThemeHtmlStyles } from "@/lib/utils/themeHtmlStyles"; +import { getSourceUrl } from "@/lib/utils/sourceUrlHelpers"; +import { Popover, PopoverContent, PopoverTrigger } from "@/lib/components/ui/popover"; +import { ExternalLink } from "lucide-react"; + +interface CitationProps { + citation: CitationType; + onClick?: (citation: CitationType) => void; + maxLength?: number; +} + +/** + * Truncate text to fit within maxLength, adding ellipsis if needed + */ +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength - 3) + "..."; +} + +/** + * Extract clean filename from file_id by removing session prefix if present + * Same logic as RAGInfoPanel, but only applies if the filename has the session pattern + */ +function extractFilename(filename: string): string { + // Check if this looks like a session-prefixed filename + const hasSessionPrefix = filename.includes("web-session-") || filename.startsWith("sam_dev_user_"); + + // If it doesn't have a session prefix, return as-is + if (!hasSessionPrefix) { + return filename; + } + + // The pattern is: sam_dev_user_web-session-{uuid}_{actual_filename}_v{version}.pdf + // We need to extract just the {actual_filename}.pdf part + + // First, remove the .pdf extension at the very end (added by backend) + let cleaned = filename.replace(/\.pdf$/, ""); + + // Remove the version suffix (_v0, _v1, etc.) + cleaned = cleaned.replace(/_v\d+$/, ""); + + // Now we have: sam_dev_user_web-session-{uuid}_{actual_filename} + // Find the pattern "web-session-{uuid}_" and remove everything before and including it + const sessionPattern = /^.*web-session-[a-f0-9-]+_/; + cleaned = cleaned.replace(sessionPattern, ""); + + // Add back the .pdf extension + return cleaned + ".pdf"; +} + +/** + * Get display text for citation (filename or URL) + */ +function getCitationDisplayText(citation: CitationType, maxLength: number = 30): string { + // For web search citations, try to extract domain name even without full source data + const isWebSearch = citation.source?.metadata?.type === "web_search" || citation.type === "search"; + + if (isWebSearch && citation.source?.sourceUrl) { + try { + const url = new URL(citation.source.sourceUrl); + const domain = url.hostname.replace(/^www\./, ""); + return truncateText(domain, maxLength); + } catch { + // If URL parsing fails, fall through to other methods + } + } + + // Check if source has a URL in metadata + if (citation.source?.metadata?.link) { + try { + const url = new URL(citation.source.metadata.link); + const domain = url.hostname.replace(/^www\./, ""); + return truncateText(domain, maxLength); + } catch { + // If URL parsing fails, continue + } + } + + // If no source data but it's a search citation, try to infer from citation type + if (!citation.source && citation.type === "search") { + // For search citations without source data, show a more descriptive label + return `Web Source ${citation.sourceId + 1}`; + } + + if (!citation.source) { + return `Source ${citation.sourceId + 1}`; + } + + // The filename field contains the original filename (not the temp path) + // The source_url field contains the temp path (not useful for display) + if (citation.source.filename) { + // For KB search, filename already contains the original name + // For file search, it might have session prefix that needs extraction + const hasSessionPrefix = citation.source.filename.includes("web-session-") || citation.source.filename.startsWith("sam_dev_user_"); + + const displayName = hasSessionPrefix ? extractFilename(citation.source.filename) : citation.source.filename; + + return truncateText(displayName, maxLength); + } + + // Fallback to source URL if no filename + if (citation.source.sourceUrl) { + // Try to extract domain name or filename from URL + try { + const url = new URL(citation.source.sourceUrl); + const domain = url.hostname.replace(/^www\./, ""); + return truncateText(domain, maxLength); + } catch { + // If URL parsing fails, try to extract filename + const filename = citation.source.sourceUrl.split("/").pop() || citation.source.sourceUrl; + return truncateText(filename, maxLength); + } + } + + return `Source ${citation.sourceId + 1}`; +} + +export function Citation({ citation, onClick, maxLength = 30 }: CitationProps) { + const displayText = getCitationDisplayText(citation, maxLength); + const tooltip = getCitationTooltip(citation); + + // Check if this is a web search or deep research citation with a URL + const { url: sourceUrl, sourceType } = getSourceUrl(citation.source); + const isWebSearch = sourceType === "web_search" || citation.type === "search"; + const isDeepResearch = sourceType === "deep_research" || citation.type === "research"; + const hasClickableUrl = (isWebSearch || isDeepResearch) && sourceUrl; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // For web search and deep research citations with URLs, open the URL directly + if (hasClickableUrl) { + window.open(sourceUrl, "_blank", "noopener,noreferrer"); + return; + } + + // For RAG citations, use onClick handler (to open RAG panel) + if (onClick) { + onClick(citation); + } + }; + + return ( + + ); +} + +/** + * Bundled Citations Component + * Displays multiple citations grouped together at the end of a paragraph + * If only one citation, shows it as a regular citation badge + * If multiple, shows first citation name with "+X" in the same bubble + */ +interface BundledCitationsProps { + citations: CitationType[]; + onCitationClick?: (citation: CitationType) => void; +} + +export function BundledCitations({ citations, onCitationClick }: BundledCitationsProps) { + const [isDark, setIsDark] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const timeoutRef = useRef | null>(null); + const showTimeout = 150; + const hideTimeout = 150; + + // Detect dark mode + React.useEffect(() => { + const checkDarkMode = () => { + setIsDark(document.documentElement.classList.contains("dark")); + }; + + checkDarkMode(); + + const observer = new MutationObserver(checkDarkMode); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); + + // Cleanup timeout on unmount + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const handleMouseEnter = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setIsOpen(true); + }, showTimeout); + }, []); + + const handleMouseLeave = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setIsOpen(false); + }, hideTimeout); + }, []); + + const handleContentMouseEnter = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, []); + + const handleContentMouseLeave = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setIsOpen(false); + }, hideTimeout); + }, []); + + if (citations.length === 0) return null; + + // Get unique citations (deduplicate by sourceId) + const uniqueCitations = citations.filter((citation, index, self) => index === self.findIndex(c => c.sourceId === citation.sourceId && c.type === citation.type)); + + // If only one citation, render it as a regular citation badge + if (uniqueCitations.length === 1) { + return ; + } + + // Multiple citations - show first citation name + "+X" in same bubble + const firstCitation = uniqueCitations[0]; + const remainingCount = uniqueCitations.length - 1; + const firstDisplayText = getCitationDisplayText(firstCitation, 20); + const tooltip = getCitationTooltip(firstCitation); + + // Check if this is a web search or deep research citation + const { url: sourceUrl, sourceType } = getSourceUrl(firstCitation.source); + const isWebSearch = sourceType === "web_search" || firstCitation.type === "search"; + const isDeepResearch = sourceType === "deep_research" || firstCitation.type === "research"; + const hasClickableUrl = (isWebSearch || isDeepResearch) && sourceUrl; + + const handleFirstCitationClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // For web search and deep research citations, open the URL directly + if (hasClickableUrl && sourceUrl) { + window.open(sourceUrl, "_blank", "noopener,noreferrer"); + return; + } + + // For RAG citations, use onClick handler (to open RAG panel) + if (onCitationClick) { + onCitationClick(firstCitation); + } + }; + + return ( + + + + + +
+
+

All Sources · {uniqueCitations.length}

+
+ {uniqueCitations.map((citation, index) => { + const displayText = getCitationDisplayText(citation, 50); + const { url: sourceUrl, sourceType } = getSourceUrl(citation.source); + const isWebSearch = sourceType === "web_search" || citation.type === "search"; + const isDeepResearch = sourceType === "deep_research" || citation.type === "research"; + const hasClickableUrl = (isWebSearch || isDeepResearch) && sourceUrl; + + // Get favicon for web sources (both web search and deep research) + let favicon = null; + if ((isWebSearch || isDeepResearch) && sourceUrl) { + try { + const url = new URL(sourceUrl); + const domain = url.hostname; + favicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + } catch { + // Ignore favicon errors + } + } + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (hasClickableUrl && sourceUrl) { + window.open(sourceUrl, "_blank", "noopener,noreferrer"); + } else if (onCitationClick) { + onCitationClick(citation); + } + }; + + return ( + + ); + })} +
+
+
+ ); +} + +/** + * Component to render text with embedded citations + */ +interface TextWithCitationsProps { + text: string; + citations: CitationType[]; + onCitationClick?: (citation: CitationType) => void; +} + +/** + * Parse a citation ID and return its components + * Handles both formats: + * - s{turn}r{index} (e.g., "s0r0", "s1r2") -> type: "search" + * - research{N} (e.g., "research0") -> type: "research" + */ +function parseCitationIdLocal(citationId: string): { type: "search" | "research"; sourceId: number } | null { + // Try sTrN format first + const searchMatch = citationId.match(/^s(\d+)r(\d+)$/); + if (searchMatch) { + return { + type: "search", + sourceId: parseInt(searchMatch[2], 10), // Use result index as sourceId + }; + } + + // Try research format + const researchMatch = citationId.match(/^research(\d+)$/); + if (researchMatch) { + return { + type: "research", + sourceId: parseInt(researchMatch[1], 10), + }; + } + + return null; +} + +/** + * Parse individual citations from a comma-separated content string + * Supports: s0r0, s1r2, research0, research1 + */ +function parseMultiCitationIds(content: string): Array<{ type: "search" | "research"; sourceId: number; citationId: string }> { + const results: Array<{ type: "search" | "research"; sourceId: number; citationId: string }> = []; + let individualMatch; + + INDIVIDUAL_CITATION_PATTERN.lastIndex = 0; + while ((individualMatch = INDIVIDUAL_CITATION_PATTERN.exec(content)) !== null) { + const citationId = individualMatch[1]; // The captured citation ID (s0r0 or research0) + const parsed = parseCitationIdLocal(citationId); + + if (parsed) { + results.push({ + type: parsed.type, + sourceId: parsed.sourceId, + citationId: citationId, + }); + } + } + + return results; +} + +/** + * Combined pattern that matches both single and multi-citation formats + * This ensures we process them in order of appearance + * Supports: s0r0, s1r2, research0, research1 + */ +const COMBINED_CITATION_PATTERN = /\[?\[cite:((?:s\d+r\d+|research\d+)(?:\s*,\s*(?:cite:)?(?:s\d+r\d+|research\d+))*)\]\]?/g; + +/** + * Process text node content to replace citation markers with React components + */ +function processTextWithCitations(textContent: string, citations: CitationType[], onCitationClick?: (citation: CitationType) => void): ReactNode[] { + const result: ReactNode[] = []; + let lastIndex = 0; + let match; + let pendingCitations: CitationType[] = []; + + // Reset regex + COMBINED_CITATION_PATTERN.lastIndex = 0; + + while ((match = COMBINED_CITATION_PATTERN.exec(textContent)) !== null) { + // Add text before citation + if (match.index > lastIndex) { + // Flush pending citations before text + if (pendingCitations.length > 0) { + result.push(); + pendingCitations = []; + } + result.push(textContent.substring(lastIndex, match.index)); + } + + // Parse the citation content (could be single or comma-separated) + const [, content] = match; + const citationIds = parseMultiCitationIds(content); + + for (const { citationId } of citationIds) { + // Look up by citationId (e.g., "s0r0" or "research0") + const citation = citations.find(c => c.citationId === citationId); + + if (citation) { + pendingCitations.push(citation); + } + } + + lastIndex = match.index + match[0].length; + } + + // Add remaining text + if (lastIndex < textContent.length) { + // Flush pending citations before remaining text + if (pendingCitations.length > 0) { + result.push(); + pendingCitations = []; + } + result.push(textContent.substring(lastIndex)); + } else if (pendingCitations.length > 0) { + // Flush any remaining citations at the end + result.push(); + } + + return result; +} + +export function TextWithCitations({ text, citations, onCitationClick }: TextWithCitationsProps) { + // Create parser options to process text nodes and replace citation markers + const parserOptions: HTMLReactParserOptions = useMemo( + () => ({ + replace: (domNode: DOMNode) => { + // Process text nodes to find and replace citation markers + if (domNode.type === "text" && domNode instanceof DomText) { + const textContent = domNode.data; + + // Check if this text contains citation markers (single or multi) + COMBINED_CITATION_PATTERN.lastIndex = 0; + if (COMBINED_CITATION_PATTERN.test(textContent)) { + COMBINED_CITATION_PATTERN.lastIndex = 0; + const processed = processTextWithCitations(textContent, citations, onCitationClick); + if (processed.length > 0) { + return <>{processed}; + } + } + } + + // Handle links - add target blank + if (domNode instanceof Element && domNode.name === "a") { + domNode.attribs.target = "_blank"; + domNode.attribs.rel = "noopener noreferrer"; + } + + return undefined; + }, + }), + [citations, onCitationClick] + ); + + if (citations.length === 0) { + return {text}; + } + + try { + // Convert markdown to HTML + const rawHtml = marked.parse(text, { gfm: true }) as string; + const cleanHtml = DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } }); + + // Parse HTML and inject citations + const reactElements = parse(cleanHtml, parserOptions); + + return
{reactElements}
; + } catch { + return {text}; + } +} diff --git a/client/webui/frontend/src/lib/components/chat/ConnectionRequiredModal.tsx b/client/webui/frontend/src/lib/components/chat/ConnectionRequiredModal.tsx new file mode 100644 index 000000000..18ce86817 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/ConnectionRequiredModal.tsx @@ -0,0 +1,57 @@ +/** + * Connection Required Modal Component + * + * Shows when user tries to select an enterprise source that requires authentication + * Provides option to navigate to connections panel to authenticate + */ + +import React from "react"; +import { AlertCircle, ExternalLink } from "lucide-react"; +import { Button } from "@/lib/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/lib/components/ui/dialog"; + +interface ConnectionRequiredModalProps { + isOpen: boolean; + onClose: () => void; + sourceName: string; + onNavigateToConnections: () => void; +} + +export const ConnectionRequiredModal: React.FC = ({ isOpen, onClose, sourceName, onNavigateToConnections }) => { + return ( + !open && onClose()}> + + + + + Connection Required + + You need to connect your {sourceName} account before using it in deep research. + + + {/* Content */} +
+

+ How to connect: +

+
    +
  1. Go to the Connections panel
  2. +
  3. Click "Connect" next to {sourceName}
  4. +
  5. Authenticate with your account
  6. +
  7. Return here to enable the source
  8. +
+
+ + + + + +
+
+ ); +}; diff --git a/client/webui/frontend/src/lib/components/chat/MessageHoverButtons.tsx b/client/webui/frontend/src/lib/components/chat/MessageHoverButtons.tsx index cdec8b4a3..a6320756b 100644 --- a/client/webui/frontend/src/lib/components/chat/MessageHoverButtons.tsx +++ b/client/webui/frontend/src/lib/components/chat/MessageHoverButtons.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Copy, Check } from "lucide-react"; import { Button } from "@/lib/components/ui"; import { cn } from "@/lib/utils"; @@ -9,21 +9,26 @@ import { TTSButton } from "./TTSButton"; interface MessageHoverButtonsProps { message: MessageFE; className?: string; + /** Optional text content override */ + textContentOverride?: string; } -export const MessageHoverButtons: React.FC = ({ message, className }) => { +export const MessageHoverButtons: React.FC = ({ message, className, textContentOverride }) => { const { addNotification } = useChatContext(); const [isCopied, setIsCopied] = useState(false); const buttonRef = useRef(null); - // Extract text content from message parts + // Extract text content from message parts, or use override if provided const getTextContent = useCallback((): string => { + if (textContentOverride) { + return textContentOverride; + } if (!message.parts || message.parts.length === 0) { return ""; } const textParts = message.parts.filter(p => p.kind === "text") as TextPart[]; return textParts.map(p => p.text).join(""); - }, [message.parts]); + }, [message.parts, textContentOverride]); // Copy functionality const handleCopy = useCallback(() => { @@ -76,6 +81,17 @@ export const MessageHoverButtons: React.FC = ({ messag }; }, [handleCopy]); + // Create a synthetic message with text content override + const messageForTTS = useMemo((): MessageFE => { + if (!textContentOverride) { + return message; + } + return { + ...message, + parts: [{ kind: "text", text: textContentOverride }], + }; + }, [message, textContentOverride]); + // Don't show buttons for status messages if (message.isStatusBubble || message.isStatusMessage) { return null; @@ -84,7 +100,7 @@ export const MessageHoverButtons: React.FC = ({ messag return (
{/* TTS Button - for AI messages */} - {!message.isUser && } + {!message.isUser && } {/* Copy button - all messages */}
)} diff --git a/client/webui/frontend/src/lib/components/chat/SessionSearch.tsx b/client/webui/frontend/src/lib/components/chat/SessionSearch.tsx index 2b4ee1953..0322c9694 100644 --- a/client/webui/frontend/src/lib/components/chat/SessionSearch.tsx +++ b/client/webui/frontend/src/lib/components/chat/SessionSearch.tsx @@ -1,11 +1,11 @@ import { useState, useCallback, useEffect } from "react"; import { Search, X } from "lucide-react"; -import { Input } from "@/lib/components/ui/input"; -import { Button } from "@/lib/components/ui/button"; -import { Badge } from "@/lib/components/ui/badge"; -import { useDebounce } from "@/lib/hooks/useDebounce"; -import type { Session } from "@/lib/types"; + import { api } from "@/lib/api"; +import { ProjectBadge } from "@/lib/components/chat"; +import { Button, Input } from "@/lib/components/ui"; +import { useDebounce } from "@/lib/hooks"; +import type { Session } from "@/lib/types"; interface SessionSearchProps { onSessionSelect: (sessionId: string) => void; @@ -98,11 +98,7 @@ export const SessionSearch = ({ onSessionSelect, projectId }: SessionSearchProps diff --git a/client/webui/frontend/src/lib/components/chat/artifact/ArtifactBar.tsx b/client/webui/frontend/src/lib/components/chat/artifact/ArtifactBar.tsx index 64f6e8c1a..d3ac56e6d 100644 --- a/client/webui/frontend/src/lib/components/chat/artifact/ArtifactBar.tsx +++ b/client/webui/frontend/src/lib/components/chat/artifact/ArtifactBar.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from "react"; -import { Download, ChevronDown, Trash, Info, ChevronUp, CircleAlert } from "lucide-react"; +import { Download, ChevronDown, Trash, Info, ChevronUp, CircleAlert, Pencil } from "lucide-react"; -import { Button, Spinner, Badge } from "@/lib/components/ui"; -import { FileIcon } from "../file/FileIcon"; -import { cn } from "@/lib/utils"; +import { Button, Spinner } from "@/lib/components/ui"; +import { cn, formatBytes } from "@/lib/utils"; + +import { FileIcon, ProjectBadge } from "../file"; const ErrorState: React.FC<{ message: string }> = ({ message }) => (
@@ -26,6 +27,7 @@ export interface ArtifactBarProps { onDelete?: () => void; onInfo?: () => void; onExpand?: () => void; + onEdit?: () => void; }; // For creation progress bytesTransferred?: number; @@ -108,7 +110,7 @@ export const ArtifactBar: React.FC = ({ switch (status) { case "in-progress": return { - text: bytesTransferred ? `Creating... ${(bytesTransferred / 1024).toFixed(1)}KB` : "Creating...", + text: bytesTransferred ? `Creating... ${formatBytes(bytesTransferred)}` : "Creating...", className: "text-[var(--color-info-wMain)]", }; case "failed": @@ -118,7 +120,7 @@ export const ArtifactBar: React.FC = ({ }; case "completed": return { - text: size ? `${(size / 1024).toFixed(1)}KB` : "", + text: size ? formatBytes(size) : "", }; default: return { @@ -211,11 +213,7 @@ export const ArtifactBar: React.FC = ({ {hasDescription ? displayDescription : filename.length > 50 ? `${filename.substring(0, 47)}...` : filename}
{/* Project badge */} - {source === "project" && ( - - Project - - )} + {source === "project" && }
{/* Secondary line: Filename (if description shown) or status */} @@ -298,6 +296,24 @@ export const ArtifactBar: React.FC = ({ )} + {status === "completed" && actions?.onEdit && !isDeleted && ( + + )} + {status === "completed" && actions?.onDelete && !isDeleted && (
@@ -417,32 +435,7 @@ export const ArtifactMessage: React.FC = props => { const infoContent = useMemo(() => { if (!isInfoExpanded || !artifact) return null; - return ( -
- {artifact.description && ( -
- Description: -
{artifact.description}
-
- )} -
-
- Size: -
{formatBytes(artifact.size)}
-
-
- Modified: -
{formatRelativeTime(artifact.last_modified)}
-
-
- {artifact.mime_type && ( -
- Type: -
{artifact.mime_type}
-
- )} -
- ); + return ; }, [isInfoExpanded, artifact]); // Determine what content to show in expanded area - can show both info and content @@ -479,7 +472,7 @@ export const ArtifactMessage: React.FC = props => { mimeType={fileMimeType} size={fileAttachment?.size} status={props.status} - expandable={isExpandable && context === "chat"} // Allow expansion in chat context for user-controllable files + expandable={isExpandable && context === "chat" && !isDeepResearchReportFilename(fileName)} // Allow expansion in chat context for user-controllable files, but not for deep research reports (shown inline) expanded={isExpanded || isInfoExpanded} onToggleExpand={isExpandable && context === "chat" ? toggleExpanded : undefined} actions={actions} diff --git a/client/webui/frontend/src/lib/components/chat/file/FileDetails.tsx b/client/webui/frontend/src/lib/components/chat/file/FileDetails.tsx new file mode 100644 index 000000000..7824656c8 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/file/FileDetails.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +import { formatBytes, formatRelativeTime } from "@/lib/utils/format"; + +interface FileDetailsProps { + description?: string; + size: number; + lastModified: string; + mimeType?: string; +} + +export const FileDetails: React.FC = ({ description, size, lastModified, mimeType }) => { + return ( +
+ {description && ( +
+ Description: +
{description}
+
+ )} +
+
+ Size: +
{formatBytes(size)}
+
+
+ Modified: +
{formatRelativeTime(lastModified)}
+
+
+ {mimeType && ( +
+ Type: +
{mimeType}
+
+ )} +
+ ); +}; diff --git a/client/webui/frontend/src/lib/components/chat/file/FileLabel.tsx b/client/webui/frontend/src/lib/components/chat/file/FileLabel.tsx new file mode 100644 index 000000000..9fe5bce43 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/file/FileLabel.tsx @@ -0,0 +1,16 @@ +import { formatBytes } from "@/lib/utils"; +import { File } from "lucide-react"; + +export const FileLabel = ({ fileName, fileSize }: { fileName: string; fileSize: number }) => { + return ( +
+ +
+
+ {fileName} +
+
{formatBytes(fileSize)}
+
+
+ ); +}; diff --git a/client/webui/frontend/src/lib/components/chat/file/ProjectBadge.tsx b/client/webui/frontend/src/lib/components/chat/file/ProjectBadge.tsx new file mode 100644 index 000000000..e05f2853b --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/file/ProjectBadge.tsx @@ -0,0 +1,14 @@ +import { Badge, Tooltip, TooltipContent, TooltipTrigger } from "@/lib"; + +export const ProjectBadge = ({ text = "Project", className = "" }: { text?: string; className?: string }) => { + return ( + + + + {text} + + + {text} + + ); +}; diff --git a/client/webui/frontend/src/lib/components/chat/file/index.ts b/client/webui/frontend/src/lib/components/chat/file/index.ts index 4e13b04ab..b5c8fc5d9 100644 --- a/client/webui/frontend/src/lib/components/chat/file/index.ts +++ b/client/webui/frontend/src/lib/components/chat/file/index.ts @@ -1,5 +1,7 @@ export * from "./ArtifactMessage"; export * from "./FileBadge"; +export * from "./FileDetails"; export * from "./FileIcon"; export * from "./FileMessage"; export * from "./fileUtils"; +export * from "./ProjectBadge"; diff --git a/client/webui/frontend/src/lib/components/chat/index.ts b/client/webui/frontend/src/lib/components/chat/index.ts index 87785d710..6d0ccfe6d 100644 --- a/client/webui/frontend/src/lib/components/chat/index.ts +++ b/client/webui/frontend/src/lib/components/chat/index.ts @@ -1,6 +1,8 @@ export { AudioRecorder } from "./AudioRecorder"; export { ChatInputArea } from "./ChatInputArea"; export { ChatMessage } from "./ChatMessage"; +export { ChatSessionDeleteDialog } from "./ChatSessionDeleteDialog"; +export { ChatSessionDialog } from "./ChatSessionDialog"; export { ChatSessions } from "./ChatSessions"; export { ChatSidePanel } from "./ChatSidePanel"; export { LoadingMessageRow } from "./LoadingMessageRow"; @@ -9,4 +11,5 @@ export { MoveSessionDialog } from "./MoveSessionDialog"; export { VariableDialog } from "./VariableDialog"; export { SessionSearch } from "./SessionSearch"; export { MessageHoverButtons } from "./MessageHoverButtons"; +export * from "./file"; export * from "./selection"; diff --git a/client/webui/frontend/src/lib/components/chat/preview/ContentRenderer.tsx b/client/webui/frontend/src/lib/components/chat/preview/ContentRenderer.tsx index f17c057bc..a701b027c 100644 --- a/client/webui/frontend/src/lib/components/chat/preview/ContentRenderer.tsx +++ b/client/webui/frontend/src/lib/components/chat/preview/ContentRenderer.tsx @@ -1,15 +1,18 @@ import React from "react"; import { AudioRenderer, CsvRenderer, HtmlRenderer, ImageRenderer, MarkdownRenderer, MermaidRenderer, StructuredDataRenderer, TextRenderer } from "./Renderers"; +import type { RAGSearchResult } from "@/lib/types"; interface ContentRendererProps { content: string; rendererType: string; mime_type?: string; setRenderError: (error: string | null) => void; + isStreaming?: boolean; + ragData?: RAGSearchResult; } -export const ContentRenderer: React.FC = ({ content, rendererType, mime_type, setRenderError }) => { +export const ContentRenderer: React.FC = ({ content, rendererType, mime_type, setRenderError, isStreaming, ragData }) => { switch (rendererType) { case "csv": return ; @@ -23,10 +26,10 @@ export const ContentRenderer: React.FC = ({ content, rende case "image": return ; case "markdown": - return ; + return ; case "audio": return ; default: - return ; + return ; } }; diff --git a/client/webui/frontend/src/lib/components/chat/preview/Renderers/MarkdownRenderer.tsx b/client/webui/frontend/src/lib/components/chat/preview/Renderers/MarkdownRenderer.tsx index 1e99929d5..13c01d922 100644 --- a/client/webui/frontend/src/lib/components/chat/preview/Renderers/MarkdownRenderer.tsx +++ b/client/webui/frontend/src/lib/components/chat/preview/Renderers/MarkdownRenderer.tsx @@ -1,16 +1,41 @@ -import React from "react"; +import React, { useMemo } from "react"; -import { MarkdownHTMLConverter } from "@/lib/components"; +import { MarkdownWrapper } from "@/lib/components"; import type { BaseRendererProps } from "."; import { useCopy } from "../../../../hooks/useCopy"; +import { getThemeHtmlStyles } from "@/lib/utils/themeHtmlStyles"; +import type { RAGSearchResult } from "@/lib/types"; +import { parseCitations } from "@/lib/utils/citations"; +import { TextWithCitations } from "@/lib/components/chat/Citation"; -export const MarkdownRenderer: React.FC = ({ content }) => { +interface MarkdownRendererProps extends BaseRendererProps { + ragData?: RAGSearchResult; +} + +/** + * MarkdownRenderer - Renders markdown content with citation support + * + * Uses MarkdownWrapper for streaming content (smooth text animation) + * Uses TextWithCitations for non-streaming content (citation support) + */ +export const MarkdownRenderer: React.FC = ({ content, ragData, isStreaming }) => { const { ref, handleKeyDown } = useCopy(); + // Parse citations from content using ragData + const citations = useMemo(() => { + return parseCitations(content, ragData); + }, [content, ragData]); + return (
- {content} + {isStreaming ? ( + + ) : ( +
+ +
+ )}
); diff --git a/client/webui/frontend/src/lib/components/chat/preview/Renderers/TextRenderer.tsx b/client/webui/frontend/src/lib/components/chat/preview/Renderers/TextRenderer.tsx index 14423d432..04e18dd04 100644 --- a/client/webui/frontend/src/lib/components/chat/preview/Renderers/TextRenderer.tsx +++ b/client/webui/frontend/src/lib/components/chat/preview/Renderers/TextRenderer.tsx @@ -1,13 +1,25 @@ import type { BaseRendererProps } from "."; import { useCopy } from "../../../../hooks/useCopy"; +import { StreamingMarkdown } from "@/lib/components"; interface TextRendererProps extends BaseRendererProps { className?: string; } -export const TextRenderer: React.FC = ({ content, className = "" }) => { +export const TextRenderer: React.FC = ({ content, className = "", isStreaming }) => { const { ref, handleKeyDown } = useCopy(); + if (isStreaming) { + // Use StreamingMarkdown for smooth rendering effect, even though it might interpret markdown. + return ( +
+
} className="whitespace-pre-wrap select-text focus-visible:outline-none" tabIndex={0} onKeyDown={handleKeyDown}> + +
+
+ ); + } + return (
diff --git a/client/webui/frontend/src/lib/components/chat/preview/Renderers/index.ts b/client/webui/frontend/src/lib/components/chat/preview/Renderers/index.ts
index f171e1857..46ae046b6 100644
--- a/client/webui/frontend/src/lib/components/chat/preview/Renderers/index.ts
+++ b/client/webui/frontend/src/lib/components/chat/preview/Renderers/index.ts
@@ -6,6 +6,7 @@ export interface BaseRendererProps {
     rendererType?: string; // Optional, for structured data renderers
     mime_type?: string; // Optional MIME type for specific renderers
     setRenderError: (error: string | null) => void; // Function to set error state
+    isStreaming?: boolean;
 }
 
 export { AudioRenderer } from "./AudioRenderer";
diff --git a/client/webui/frontend/src/lib/components/chat/preview/previewUtils.ts b/client/webui/frontend/src/lib/components/chat/preview/previewUtils.ts
index 1caaef038..0b640edf6 100644
--- a/client/webui/frontend/src/lib/components/chat/preview/previewUtils.ts
+++ b/client/webui/frontend/src/lib/components/chat/preview/previewUtils.ts
@@ -314,7 +314,6 @@ export const getFileContent = (file: FileAttachment | null) => {
     // Check if content is already plain text (from streaming)
     // @ts-expect-error - Custom property added during streaming
     if (file.isPlainText) {
-        console.log("Content is plain text from streaming, returning as-is");
         return file.content;
     }
 
diff --git a/client/webui/frontend/src/lib/components/chat/rag/RAGInfoPanel.tsx b/client/webui/frontend/src/lib/components/chat/rag/RAGInfoPanel.tsx
new file mode 100644
index 000000000..3191f7ab1
--- /dev/null
+++ b/client/webui/frontend/src/lib/components/chat/rag/RAGInfoPanel.tsx
@@ -0,0 +1,541 @@
+import React from "react";
+import { FileText, TrendingUp, Search, Link2, ChevronDown, ChevronUp, Brain, Globe, ExternalLink } from "lucide-react";
+// Web-only version - enterprise icons removed
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/lib/components/ui/tabs";
+import type { RAGSearchResult } from "@/lib/types";
+
+interface TimelineEvent {
+    type: "thinking" | "search" | "read";
+    timestamp: string;
+    content: string;
+    url?: string;
+    favicon?: string;
+    title?: string;
+    source_type?: string;
+}
+
+interface RAGInfoPanelProps {
+    ragData: RAGSearchResult[] | null;
+    enabled: boolean;
+}
+
+/**
+ * Extract clean filename from file_id by removing session prefix
+ * Example: "sam_dev_user_web-session-xxx_filename.pdf_v0.pdf" -> "filename.pdf"
+ */
+const extractFilename = (filename: string | undefined): string => {
+    if (!filename) return "Unknown";
+
+    // The pattern is: sam_dev_user_web-session-{uuid}_{actual_filename}_v{version}.pdf
+    // We need to extract just the {actual_filename}.pdf part
+
+    // First, remove the .pdf extension at the very end (added by backend)
+    let cleaned = filename.replace(/\.pdf$/, "");
+
+    // Remove the version suffix (_v0, _v1, etc.)
+    cleaned = cleaned.replace(/_v\d+$/, "");
+
+    // Now we have: sam_dev_user_web-session-{uuid}_{actual_filename}
+    // Find the pattern "web-session-{uuid}_" and remove everything before and including it
+    const sessionPattern = /^.*web-session-[a-f0-9-]+_/;
+    cleaned = cleaned.replace(sessionPattern, "");
+
+    // Add back the .pdf extension
+    return cleaned + ".pdf";
+};
+
+const SourceCard: React.FC<{
+    source: RAGSearchResult["sources"][0];
+}> = ({ source }) => {
+    const [isExpanded, setIsExpanded] = React.useState(false);
+    const contentPreview = source.contentPreview;
+    const sourceType = source.sourceType || "web";
+
+    // For image sources, use the source page link (not the imageUrl)
+    let sourceUrl: string;
+    let displayTitle: string;
+
+    if (sourceType === "image") {
+        sourceUrl = source.sourceUrl || source.metadata?.link || "";
+        displayTitle = source.metadata?.title || source.filename || "Image source";
+    } else {
+        sourceUrl = source.sourceUrl || source.url || "";
+        displayTitle = source.title || source.filename || extractFilename(source.fileId);
+    }
+
+    // Don't show content preview if it's just "Reading..." placeholder
+    const hasRealContent = contentPreview && contentPreview !== "Reading...";
+    const shouldTruncate = hasRealContent && contentPreview.length > 200;
+    const displayContent = shouldTruncate && !isExpanded ? contentPreview.substring(0, 200) + "..." : contentPreview;
+
+    // Only show score if it's a real relevance score (not the default 1.0 from deep research)
+    const showScore = source.relevanceScore !== 1.0;
+
+    return (
+        
+ {/* Source Header */} +
+
+ + {sourceUrl ? ( + + {displayTitle} + + + ) : ( + + {displayTitle} + + )} +
+ {showScore && ( +
+ + Score: {source.relevanceScore.toFixed(2)} +
+ )} +
+ + {/* Content Preview - Fixed height when collapsed - Only show if we have real content */} + {hasRealContent &&
{displayContent}
} + + {/* Expand/Collapse Button */} + {shouldTruncate && ( + + )} + + {/* Metadata (if available) */} + {source.metadata && Object.keys(source.metadata).length > 0 && ( +
+
+ Metadata +
+ {Object.entries(source.metadata).map(([key, value]) => ( +
+ {key}: + {typeof value === "object" ? JSON.stringify(value) : String(value)} +
+ ))} +
+
+
+ )} +
+ ); +}; + +export const RAGInfoPanel: React.FC = ({ ragData, enabled }) => { + if (!enabled) { + return ( +
+
+ +
RAG Sources
+
RAG source visibility is disabled in settings
+
+
+ ); + } + + if (!ragData || ragData.length === 0) { + return ( +
+
+ +
Sources
+
No sources available yet
+
Sources from web research will appear here after completion
+
+
+ ); + } + + const isAllDeepResearch = ragData.every(search => search.searchType === "deep_research" || search.searchType === "web_search"); + + // Calculate total sources across all searches (including images with valid source links) + const totalSources = ragData.reduce((sum, search) => { + const validSources = search.sources.filter(s => { + const sourceType = s.sourceType || "web"; + // For images, only count if they have a source link (not just imageUrl) + if (sourceType === "image") { + return s.sourceUrl || s.metadata?.link; + } + return true; + }); + return sum + validSources.length; + }, 0); + + // Simple source item component for deep research + const SimpleSourceItem: React.FC<{ source: RAGSearchResult["sources"][0] }> = ({ source }) => { + const sourceType = source.sourceType || "web"; + + // For image sources, use the source page link (not the imageUrl) + let url: string; + let title: string; + + if (sourceType === "image") { + url = source.sourceUrl || source.metadata?.link || ""; + title = source.metadata?.title || source.filename || "Image source"; + } else { + url = source.url || source.sourceUrl || ""; + title = source.title || source.filename || "Unknown"; + } + + const favicon = source.metadata?.favicon || (url ? `https://www.google.com/s2/favicons?domain=${url}&sz=32` : ""); + + return ( +
+ {favicon && ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {url ? ( + + {title} + + + ) : ( + + {title} + + )} +
+ ); + }; + + // Helper function to check if a source was fully fetched + const isSourceFullyFetched = (source: RAGSearchResult["sources"][0]): boolean => { + return source.metadata?.fetched === true || source.metadata?.fetch_status === "success" || (source.contentPreview ? source.contentPreview.includes("[Full Content Fetched]") : false); + }; + + // Get all unique sources grouped by fully read vs snippets (for deep research) + const { fullyReadSources, snippetSources, allUniqueSources } = (() => { + if (!isAllDeepResearch) return { fullyReadSources: [], snippetSources: [], allUniqueSources: [] }; + + const fullyReadMap = new Map(); + const snippetMap = new Map(); + + // Check if this is web_search (no fetched metadata) or deep_research (has fetched metadata) + const isWebSearch = ragData.some(search => search.searchType === "web_search"); + const isDeepResearch = ragData.some(search => search.searchType === "deep_research"); + + ragData.forEach(search => { + search.sources.forEach(source => { + const sourceType = source.sourceType || "web"; + + // For image sources: include if they have a source link (not just imageUrl) + if (sourceType === "image") { + const sourceLink = source.sourceUrl || source.metadata?.link; + if (!sourceLink) { + return; // Skip images without source links + } + // Images are always considered "fully read" if they have a source link + if (!fullyReadMap.has(sourceLink)) { + fullyReadMap.set(sourceLink, source); + } + return; + } + + const key = source.url || source.sourceUrl || source.title || ""; + if (!key) return; + + // For web_search: all sources go to fully read (no distinction) + if (isWebSearch && !isDeepResearch) { + if (!fullyReadMap.has(key)) { + fullyReadMap.set(key, source); + } + return; + } + + // For deep_research: separate into fully read vs snippets + const wasFetched = isSourceFullyFetched(source); + if (wasFetched) { + if (!fullyReadMap.has(key)) { + fullyReadMap.set(key, source); + } + // Remove from snippets if it was previously added there + snippetMap.delete(key); + } else { + // Only add to snippets if not already in fully read + if (!fullyReadMap.has(key) && !snippetMap.has(key)) { + snippetMap.set(key, source); + } + } + }); + }); + + const fullyRead = Array.from(fullyReadMap.values()); + const snippets = Array.from(snippetMap.values()); + const all = [...fullyRead, ...snippets]; + + console.log("[RAGInfoPanel] Source filtering:", { + isWebSearch, + isDeepResearch, + totalSourcesBeforeFilter: ragData.reduce((sum, s) => sum + s.sources.length, 0), + fullyReadSources: fullyRead.length, + snippetSources: snippets.length, + sampleFullyRead: fullyRead.slice(0, 3).map(s => ({ + url: s.url, + title: s.title, + fetched: s.metadata?.fetched, + fetch_status: s.metadata?.fetch_status, + contentPreview: s.contentPreview?.substring(0, 100), + hasMarker: s.contentPreview?.includes("[Full Content Fetched]"), + })), + sampleSnippets: snippets.slice(0, 3).map(s => ({ + url: s.url, + title: s.title, + fetched: s.metadata?.fetched, + fetch_status: s.metadata?.fetch_status, + contentPreview: s.contentPreview?.substring(0, 100), + hasMarker: s.contentPreview?.includes("[Full Content Fetched]"), + })), + }); + + return { fullyReadSources: fullyRead, snippetSources: snippets, allUniqueSources: all }; + })(); + + // Check if we should show grouped view (only for deep_research with both types) + const isDeepResearch = ragData.some(search => search.searchType === "deep_research"); + const showGroupedSources = isDeepResearch && (fullyReadSources.length > 0 || snippetSources.length > 0); + + // Get the title from the first ragData entry (prefer LLM-generated title, fallback to query) + const panelTitle = ragData && ragData.length > 0 ? ragData[0].title || ragData[0].query : ""; + + // Check if research is complete by looking for sources with fetched metadata + const hasAnyFetchedSources = isDeepResearch && ragData.some(search => search.sources.some(s => s.metadata?.fetched === true || s.metadata?.fetch_status === "success")); + + return ( +
+ {isAllDeepResearch ? ( + // Deep research: Show sources grouped by fully read vs snippets (only when complete) +
+
+ {/* Title section showing research question or query */} + {panelTitle && ( +
+

{panelTitle}

+
+ )} + + {/* Show grouped sources ONLY when research is complete (has fetched sources) */} + {showGroupedSources && hasAnyFetchedSources ? ( + <> + {/* Fully Read Sources Section */} + {fullyReadSources.length > 0 && ( +
+
+

+ {fullyReadSources.length} Fully Read Source{fullyReadSources.length !== 1 ? "s" : ""} +

+
+
+ {fullyReadSources.map((source, idx) => ( + + ))} +
+
+ )} + + {/* Partially Read Sources Section */} + {snippetSources.length > 0 && ( +
+
+

+ {snippetSources.length} Partially Read Source{snippetSources.length !== 1 ? "s" : ""} +

+

Search result snippets

+
+
+ {snippetSources.map((source, idx) => ( + + ))} +
+
+ )} + + ) : ( + <> +
+

{isDeepResearch && !hasAnyFetchedSources ? "Sources Explored So Far" : `${allUniqueSources.length} Sources`}

+ {isDeepResearch && !hasAnyFetchedSources &&

Research in progress...

} +
+
+ {allUniqueSources.map((source, idx) => ( + + ))} +
+ + )} +
+
+ ) : ( + // Regular RAG/web search: Show both Activity and Sources tabs + +
+ + Activity + {totalSources} Sources + +
+ + +
+

Timeline of Research Activity

+

+ {ragData.length} search{ragData.length !== 1 ? "es" : ""} performed +

+
+ +
+ {ragData.map((search, searchIdx) => { + // Build timeline events for this search + const events: TimelineEvent[] = []; + + // Add search event + events.push({ + type: "search", + timestamp: search.timestamp, + content: search.query, + }); + + // Add read events for sources that were fetched/analyzed + search.sources.forEach(source => { + if (source.url || source.title) { + const sourceType = source.metadata?.source_type || "web"; + events.push({ + type: "read", + timestamp: source.retrievedAt || search.timestamp, + content: source.title || source.url || "Unknown", + url: source.url, + favicon: source.metadata?.favicon || (source.url ? `https://www.google.com/s2/favicons?domain=${source.url}&sz=32` : ""), + title: source.title, + source_type: sourceType, + }); + } + }); + + return ( + + {events.map((event, eventIdx) => ( +
+ {/* Icon */} +
+ {event.type === "thinking" && } + {event.type === "search" && } + {event.type === "read" && + (() => { + // Web-only version - only web sources + if (event.favicon && event.favicon.trim() !== "") { + // Web source with favicon + return ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ); + } else { + // Web source without favicon or unknown + return ; + } + })()} +
+ + {/* Content */} +
+ {event.type === "search" && ( +
+ Searched for + {event.content} +
+ )} + {event.type === "read" && ( +
+ Read + {event.url ? ( + + {event.title || new URL(event.url).hostname} + + + ) : ( + {event.content} + )} +
+ )} + {event.type === "thinking" &&
{event.content}
} +
+
+ ))} +
+ ); + })} +
+
+ + +
+

All Sources

+

+ {totalSources} source{totalSources !== 1 ? "s" : ""} found across {ragData.length} search{ragData.length !== 1 ? "es" : ""} +

+
+ +
+ {ragData.map((search, searchIdx) => + search.sources + .filter(source => { + const sourceType = source.sourceType || "web"; + // Include images only if they have a source link + if (sourceType === "image") { + return source.sourceUrl || source.metadata?.link; + } + return true; + }) + .map((source, sourceIdx) => ) + )} +
+
+
+ )} +
+ ); +}; diff --git a/client/webui/frontend/src/lib/components/chat/selection/SelectableMessageContent.tsx b/client/webui/frontend/src/lib/components/chat/selection/SelectableMessageContent.tsx index 444d249ff..46e6fa664 100644 --- a/client/webui/frontend/src/lib/components/chat/selection/SelectableMessageContent.tsx +++ b/client/webui/frontend/src/lib/components/chat/selection/SelectableMessageContent.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef } from "react"; -import { useTextSelection } from "./TextSelectionProvider"; +import { useTextSelection } from "./useTextSelection"; import { getSelectedText, getSelectionRange, getSelectionBoundingRect, calculateMenuPosition, isValidSelection } from "./selectionUtils"; import type { SelectableMessageContentProps } from "./types"; diff --git a/client/webui/frontend/src/lib/components/chat/selection/TextSelectionContext.tsx b/client/webui/frontend/src/lib/components/chat/selection/TextSelectionContext.tsx new file mode 100644 index 000000000..3e80a59a8 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/selection/TextSelectionContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import type { SelectionContextValue } from "./types"; + +export const TextSelectionContext = createContext(undefined); diff --git a/client/webui/frontend/src/lib/components/chat/selection/TextSelectionProvider.tsx b/client/webui/frontend/src/lib/components/chat/selection/TextSelectionProvider.tsx index 3ed9fc4f7..93fd47fa8 100644 --- a/client/webui/frontend/src/lib/components/chat/selection/TextSelectionProvider.tsx +++ b/client/webui/frontend/src/lib/components/chat/selection/TextSelectionProvider.tsx @@ -1,16 +1,7 @@ -import React, { createContext, useContext, useState, useCallback, type ReactNode } from "react"; +import React, { useState, useCallback, type ReactNode } from "react"; import type { SelectionState, SelectionContextValue } from "./types"; - -export const TextSelectionContext = createContext(undefined); - -export const useTextSelection = () => { - const context = useContext(TextSelectionContext); - if (!context) { - throw new Error("useTextSelection must be used within TextSelectionProvider"); - } - return context; -}; +import { TextSelectionContext } from "./TextSelectionContext"; interface TextSelectionProviderProps { children: ReactNode; diff --git a/client/webui/frontend/src/lib/components/chat/selection/index.ts b/client/webui/frontend/src/lib/components/chat/selection/index.ts index a0912b0da..48eb79af3 100644 --- a/client/webui/frontend/src/lib/components/chat/selection/index.ts +++ b/client/webui/frontend/src/lib/components/chat/selection/index.ts @@ -1,5 +1,7 @@ -export { TextSelectionProvider, useTextSelection } from "./TextSelectionProvider"; +export { TextSelectionProvider } from "./TextSelectionProvider"; +export { useTextSelection } from "./useTextSelection"; +export { TextSelectionContext } from "./TextSelectionContext"; export { SelectionContextMenu } from "./SelectionContextMenu"; export { SelectableMessageContent } from "./SelectableMessageContent"; export * from "./types"; -export * from "./selectionUtils"; \ No newline at end of file +export * from "./selectionUtils"; diff --git a/client/webui/frontend/src/lib/components/chat/selection/useTextSelection.tsx b/client/webui/frontend/src/lib/components/chat/selection/useTextSelection.tsx new file mode 100644 index 000000000..72e8ed264 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/selection/useTextSelection.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { TextSelectionContext } from "./TextSelectionContext"; + +export const useTextSelection = () => { + const context = useContext(TextSelectionContext); + if (!context) { + throw new Error("useTextSelection must be used within TextSelectionProvider"); + } + return context; +}; diff --git a/client/webui/frontend/src/lib/components/common/ConfirmationDialog.tsx b/client/webui/frontend/src/lib/components/common/ConfirmationDialog.tsx index 9ee107df6..3bc28142b 100644 --- a/client/webui/frontend/src/lib/components/common/ConfirmationDialog.tsx +++ b/client/webui/frontend/src/lib/components/common/ConfirmationDialog.tsx @@ -14,8 +14,9 @@ export interface ConfirmationDialogProps { content?: React.ReactNode; description?: string; - // optional loading state for confirm action + // optional loading and enabled state for confirm action isLoading?: boolean; + isEnabled?: boolean; // optional custom action labels actionLabels?: { @@ -27,7 +28,7 @@ export interface ConfirmationDialogProps { trigger?: React.ReactNode; } -export const ConfirmationDialog: React.FC = ({ open, title, content, description, actionLabels, trigger, isLoading, onOpenChange, onConfirm, onCancel }) => { +export const ConfirmationDialog: React.FC = ({ open, title, content, description, actionLabels, trigger, isLoading, isEnabled = true, onOpenChange, onConfirm, onCancel }) => { const cancelTitle = actionLabels?.cancel ?? "Cancel"; const confirmTitle = actionLabels?.confirm ?? "Confirm"; @@ -63,7 +64,7 @@ export const ConfirmationDialog: React.FC = ({ open, ti await onConfirm(); onOpenChange(false); }} - disabled={isLoading} + disabled={isLoading || !isEnabled} > {confirmTitle} diff --git a/client/webui/frontend/src/lib/components/common/ErrorLabel.tsx b/client/webui/frontend/src/lib/components/common/ErrorLabel.tsx new file mode 100644 index 000000000..caded7230 --- /dev/null +++ b/client/webui/frontend/src/lib/components/common/ErrorLabel.tsx @@ -0,0 +1,3 @@ +export const ErrorLabel = ({ message, className }: { message?: string; className?: string }) => { + return message ?
{message}
: null; +}; diff --git a/client/webui/frontend/src/lib/components/common/FileUpload.tsx b/client/webui/frontend/src/lib/components/common/FileUpload.tsx new file mode 100644 index 000000000..aaaeeb8b4 --- /dev/null +++ b/client/webui/frontend/src/lib/components/common/FileUpload.tsx @@ -0,0 +1,183 @@ +import { useState, useRef, useEffect, type DragEvent, type ChangeEvent } from "react"; +import { X } from "lucide-react"; + +import { Button } from "@/lib/components"; +import { MessageBanner } from "@/lib/components/common"; + +/** + * Removes a file at the specified index from a FileList. + * @param prevFiles the FileList + * @param indexToRemove the index of the file to remove + * @returns new FileList with the file removed, or null if no files remain + */ +const removeAtIndex = (prevFiles: FileList | null, indexToRemove: number): FileList | null => { + if (!prevFiles) return null; + const filesArray = Array.from(prevFiles); + filesArray.splice(indexToRemove, 1); + if (filesArray.length === 0) { + return null; + } + const dataTransfer = new DataTransfer(); + filesArray.forEach(file => dataTransfer.items.add(file)); + return dataTransfer.files; +}; + +export interface FileUploadProps { + name: string; + accept: string; + multiple?: boolean; + disabled?: boolean; + testid?: string; + value?: FileList | null; + onChange: (file: FileList | null) => void; + onValidate?: (files: FileList) => { valid: boolean; error?: string }; +} + +function FileUpload({ name, accept, multiple = false, disabled = false, testid = "", value = null, onChange, onValidate }: FileUploadProps) { + const [uploadedFiles, setUploadedFiles] = useState(value); + const [isDragging, setIsDragging] = useState(false); + const [validationError, setValidationError] = useState(null); + const fileInputRef = useRef(null); + + // Sync internal state with value prop to handle external clearing + useEffect(() => { + setUploadedFiles(value); + }, [value]); + + const setSelectedFiles = (files: FileList | null) => { + if (files && files.length > 0) { + // Validate files if validation function is provided + if (onValidate) { + const validation = onValidate(files); + if (!validation.valid) { + setValidationError(validation.error || "File validation failed."); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + return; + } + } + + setValidationError(null); + setUploadedFiles(files); + onChange(files); + } else { + setValidationError(null); + setUploadedFiles(null); + onChange(null); + fileInputRef.current!.value = ""; + } + }; + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + if (disabled) { + e.currentTarget.style.cursor = "not-allowed"; + } else { + e.currentTarget.style.cursor = "default"; + } + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + if (!disabled) { + let files = e.dataTransfer.files; + + // If multiple is false and more than one file is dropped, only take the first file + if (!multiple && files.length > 1) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(files[0]); + files = dataTransfer.files; + } + + setSelectedFiles(files); + } + }; + + const handleFileChange = (e: ChangeEvent) => { + const files = e.target.files; + setSelectedFiles(files); + }; + + const handleDropZoneClick = (e: React.MouseEvent) => { + e.preventDefault(); + fileInputRef.current?.click(); + }; + + const handleClearValidationError = () => { + setValidationError(null); + }; + + const handleRemoveFile = (index: number) => { + const newFiles = removeAtIndex(uploadedFiles, index); + setUploadedFiles(newFiles); + onChange(newFiles); + + // Clear the input so the same file can be re-selected + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + return ( +
+ {validationError && ( +
+ +
+ )} + + {uploadedFiles ? ( + Array.from(uploadedFiles).map((file, index) => ( +
+
{file.name}
+ +
+ )) + ) : ( +
+ {isDragging && !disabled ? ( +
Drop file here
+ ) : ( +
+
Drag and drop file here
+
+
+
OR
+
+
+
+ +
+
+ )} +
+ )} +
+ ); +} + +export { FileUpload }; diff --git a/client/webui/frontend/src/lib/components/common/MarkdownWrapper.tsx b/client/webui/frontend/src/lib/components/common/MarkdownWrapper.tsx new file mode 100644 index 000000000..d19ab5337 --- /dev/null +++ b/client/webui/frontend/src/lib/components/common/MarkdownWrapper.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { MarkdownHTMLConverter } from "./MarkdownHTMLConverter"; +import { StreamingMarkdown } from "./StreamingMarkdown"; + +interface MarkdownWrapperProps { + content: string; + isStreaming?: boolean; + className?: string; +} + +/** + * A wrapper component that automatically chooses between StreamingMarkdown + * (for smooth animated rendering during streaming) and MarkdownHTMLConverter + * (for static content). + */ +const MarkdownWrapper: React.FC = ({ content, isStreaming, className }) => { + if (isStreaming) { + return ; + } + + return {content}; +}; + +export { MarkdownWrapper }; diff --git a/client/webui/frontend/src/lib/components/common/StreamingMarkdown.tsx b/client/webui/frontend/src/lib/components/common/StreamingMarkdown.tsx new file mode 100644 index 000000000..3ea856552 --- /dev/null +++ b/client/webui/frontend/src/lib/components/common/StreamingMarkdown.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { MarkdownHTMLConverter } from "./MarkdownHTMLConverter"; +import { useStreamingSpeed, useStreamingAnimation } from "@/lib/hooks"; + +interface StreamingMarkdownProps { + content: string; + className?: string; +} + +const StreamingMarkdown: React.FC = ({ content, className }) => { + const { state, contentRef } = useStreamingSpeed(content); + const displayedContent = useStreamingAnimation(state, contentRef); + + return {displayedContent}; +}; + +export { StreamingMarkdown }; diff --git a/client/webui/frontend/src/lib/components/common/index.ts b/client/webui/frontend/src/lib/components/common/index.ts index be1f4bcd6..f67ef53cc 100644 --- a/client/webui/frontend/src/lib/components/common/index.ts +++ b/client/webui/frontend/src/lib/components/common/index.ts @@ -1,9 +1,13 @@ export { ConfirmationDialog } from "./ConfirmationDialog"; export { EmptyState } from "./EmptyState"; export { ErrorDialog } from "./ErrorDialog"; +export { ErrorLabel } from "./ErrorLabel"; +export { FileUpload } from "./FileUpload"; export { Footer } from "./Footer"; export { GridCard } from "./GridCard"; export { LoadingBlocker } from "./LoadingBlocker"; export { MarkdownHTMLConverter } from "./MarkdownHTMLConverter"; +export * from "./MarkdownWrapper"; export { MessageBanner } from "./MessageBanner"; export * from "./messageColourVariants"; +export * from "./StreamingMarkdown"; diff --git a/client/webui/frontend/src/lib/components/index.ts b/client/webui/frontend/src/lib/components/index.ts index fc321bc25..f534928b6 100644 --- a/client/webui/frontend/src/lib/components/index.ts +++ b/client/webui/frontend/src/lib/components/index.ts @@ -7,10 +7,33 @@ export * from "./navigation"; export * from "./chat"; export * from "./settings"; -export { MarkdownHTMLConverter, MessageBanner, EmptyState, ErrorDialog, ConfirmationDialog, LoadingBlocker, messageColourVariants } from "./common"; +export { MarkdownHTMLConverter, MarkdownWrapper, MessageBanner, EmptyState, ErrorDialog, ConfirmationDialog, LoadingBlocker, messageColourVariants, StreamingMarkdown } from "./common"; export * from "./header"; export * from "./pages"; export * from "./agents"; +export * from "./workflows"; +// Export workflow visualization components (selective to avoid conflicts with activities) +export { + WorkflowVisualizationPage, + buildWorkflowNavigationUrl, + WorkflowDiagram, + WorkflowNodeRenderer, + WorkflowNodeDetailPanel, + WorkflowDetailsSidePanel, + StartNode, + EndNode, + AgentNode, + WorkflowRefNode, + MapNode, + LoopNode, + SwitchNode, + ConditionPillNode, + EdgeLayer, + processWorkflowConfig, + LAYOUT_CONSTANTS, +} from "./workflowVisualization"; +export type { WorkflowPanelView, NodeProps, WorkflowVisualNodeType } from "./workflowVisualization"; +// Note: LayoutNode, Edge, LayoutResult types not exported here to avoid conflicts with activities export * from "./jsonViewer"; export * from "./projects"; diff --git a/client/webui/frontend/src/lib/components/jsonViewer/JSONViewer.tsx b/client/webui/frontend/src/lib/components/jsonViewer/JSONViewer.tsx index 8451a4346..b7d541ea8 100644 --- a/client/webui/frontend/src/lib/components/jsonViewer/JSONViewer.tsx +++ b/client/webui/frontend/src/lib/components/jsonViewer/JSONViewer.tsx @@ -41,9 +41,11 @@ interface JSONViewerProps { data: JSONValue; maxDepth?: number; className?: string; + /** Root name label. Set to empty string to hide. Defaults to empty (hidden). */ + rootName?: string; } -export const JSONViewer: React.FC = ({ data, maxDepth = 2, className = "" }) => { +export const JSONViewer: React.FC = ({ data, maxDepth = 2, className = "", rootName = "" }) => { const { currentTheme } = useThemeContext(); const jsonEditorTheme = useMemo(() => { @@ -78,7 +80,7 @@ export const JSONViewer: React.FC = ({ data, maxDepth = 2, clas return (
- +
); }; diff --git a/client/webui/frontend/src/lib/components/navigation/NavigationButton.tsx b/client/webui/frontend/src/lib/components/navigation/NavigationButton.tsx index a66e4c5fe..91bdf4cc3 100644 --- a/client/webui/frontend/src/lib/components/navigation/NavigationButton.tsx +++ b/client/webui/frontend/src/lib/components/navigation/NavigationButton.tsx @@ -47,7 +47,7 @@ export const NavigationButton: React.FC = ({ item, isActive {label} {badge && ( - + {badge} )} diff --git a/client/webui/frontend/src/lib/components/navigation/NavigationList.tsx b/client/webui/frontend/src/lib/components/navigation/NavigationList.tsx index ede0979a3..47ff4bbfc 100644 --- a/client/webui/frontend/src/lib/components/navigation/NavigationList.tsx +++ b/client/webui/frontend/src/lib/components/navigation/NavigationList.tsx @@ -21,15 +21,17 @@ export const NavigationList: React.FC = ({ items, bottomIte // When authorization is enabled, show menu with user info and settings/logout const { configUseAuthorization, configFeatureEnablement } = useConfigContext(); const logoutEnabled = configUseAuthorization && configFeatureEnablement?.logout ? true : false; + const { userInfo, logout } = useAuthContext(); + const userName = typeof userInfo?.username === "string" ? userInfo.username : "Guest"; const handleSettingsClick = () => { setMenuOpen(false); setSettingsDialogOpen(true); }; - const handleLogoutClick = () => { + const handleLogoutClick = async () => { setMenuOpen(false); - logout(); + await logout(); }; return ( @@ -76,8 +78,10 @@ export const NavigationList: React.FC = ({ items, bottomIte
- - {typeof userInfo?.username === "string" ? userInfo.username : "Guest"} + +
+ {userName} +
): N }, { id: "agentMesh", - label: "Agents", + label: "Agent Mesh", icon: Bot, }, ]; @@ -58,7 +58,7 @@ export const topNavigationItems: NavigationItem[] = [ }, { id: "agentMesh", - label: "Agents", + label: "Agent Mesh", icon: Bot, }, { diff --git a/client/webui/frontend/src/lib/components/pages/AgentMeshPage.tsx b/client/webui/frontend/src/lib/components/pages/AgentMeshPage.tsx index e14af9d1e..f3e966136 100644 --- a/client/webui/frontend/src/lib/components/pages/AgentMeshPage.tsx +++ b/client/webui/frontend/src/lib/components/pages/AgentMeshPage.tsx @@ -1,30 +1,73 @@ +import { useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; + import { Button, EmptyState, Header } from "@/lib/components"; import { AgentMeshCards } from "@/lib/components/agents"; +import { WorkflowList } from "@/lib/components/workflows"; import { useChatContext } from "@/lib/hooks"; +import { isWorkflowAgent } from "@/lib/utils/agentUtils"; import { RefreshCcw } from "lucide-react"; +type AgentMeshTab = "agents" | "workflows"; + export function AgentMeshPage() { const { agents, agentsLoading, agentsError, agentsRefetch } = useChatContext(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Read active tab from URL, default to "agents" + const activeTab: AgentMeshTab = (searchParams.get("tab") as AgentMeshTab) || "agents"; + + const setActiveTab = (tab: AgentMeshTab) => { + if (tab === "agents") { + // Remove tab param for default tab + searchParams.delete("tab"); + } else { + searchParams.set("tab", tab); + } + setSearchParams(searchParams); + }; + + const { regularAgents, workflowAgents } = useMemo(() => { + const regular = agents.filter(agent => !isWorkflowAgent(agent)); + const workflows = agents.filter(agent => isWorkflowAgent(agent)); + return { regularAgents: regular, workflowAgents: workflows }; + }, [agents]); + + const tabs = [ + { + id: "agents", + label: "Agents", + isActive: activeTab === "agents", + onClick: () => setActiveTab("agents"), + }, + { + id: "workflows", + label: "Workflows", + isActive: activeTab === "workflows", + onClick: () => setActiveTab("workflows"), + }, + ]; return (
agentsRefetch()}> + , ]} /> {agentsLoading ? ( - + ) : agentsError ? ( - + ) : (
- + {activeTab === "agents" ? : }
)}
diff --git a/client/webui/frontend/src/lib/components/pages/ChatPage.tsx b/client/webui/frontend/src/lib/components/pages/ChatPage.tsx index 1968f1605..2f4dbc117 100644 --- a/client/webui/frontend/src/lib/components/pages/ChatPage.tsx +++ b/client/webui/frontend/src/lib/components/pages/ChatPage.tsx @@ -4,20 +4,12 @@ import { PanelLeftIcon } from "lucide-react"; import type { ImperativePanelHandle } from "react-resizable-panels"; import { Header } from "@/lib/components/header"; -import { ChatInputArea, ChatMessage, LoadingMessageRow } from "@/lib/components/chat"; -import type { TextPart } from "@/lib/types"; -import { Button, ChatMessageList, CHAT_STYLES, Badge } from "@/lib/components/ui"; -import { Spinner } from "@/lib/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/lib/components/ui/tooltip"; -import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/lib/components/ui/resizable"; import { useChatContext, useTaskContext, useThemeContext } from "@/lib/hooks"; import { useProjectContext } from "@/lib/providers"; - -import { ChatSidePanel } from "../chat/ChatSidePanel"; -import { ChatSessionDialog } from "../chat/ChatSessionDialog"; -import { SessionSidePanel } from "../chat/SessionSidePanel"; -import { ChatSessionDeleteDialog } from "../chat/ChatSessionDeleteDialog"; -import type { ChatMessageListRef } from "../ui/chat/chat-message-list"; +import type { TextPart } from "@/lib/types"; +import { ChatInputArea, ChatMessage, ChatSessionDialog, ChatSessionDeleteDialog, ChatSidePanel, LoadingMessageRow, ProjectBadge, SessionSidePanel } from "@/lib/components/chat"; +import { Button, ChatMessageList, CHAT_STYLES, ResizablePanelGroup, ResizablePanel, ResizableHandle, Spinner, Tooltip, TooltipContent, TooltipTrigger } from "@/lib/components/ui"; +import type { ChatMessageListRef } from "@/lib/components/ui/chat/chat-message-list"; // Constants for sidepanel behavior const COLLAPSED_SIZE = 4; // icon-only mode size @@ -165,7 +157,7 @@ export function ChatPage() { return () => { setTaskIdInSidePanel(currentTaskId); - openSidePanelTab("workflow"); + openSidePanelTab("activity"); }; }, [currentTaskId, setTaskIdInSidePanel, openSidePanelTab]); @@ -201,11 +193,7 @@ export function ChatPage() {

{pageTitle}

- {activeProject && ( - - {activeProject.name} - - )} + {activeProject && }
} breadcrumbs={breadcrumbs} @@ -247,7 +235,9 @@ export function ChatPage() { {messages.map((message, index) => { const isLastWithTaskId = !!(message.taskId && lastMessageIndexByTaskId.get(message.taskId) === index); const messageKey = message.metadata?.messageId || `temp-${index}`; - return ; + const isLastMessage = index === messages.length - 1; + const shouldStream = isLastMessage && isResponding && !message.isUser; + return ; })}
diff --git a/client/webui/frontend/src/lib/components/pages/PromptsPage.tsx b/client/webui/frontend/src/lib/components/pages/PromptsPage.tsx index 1c245fb08..33a332872 100644 --- a/client/webui/frontend/src/lib/components/pages/PromptsPage.tsx +++ b/client/webui/frontend/src/lib/components/pages/PromptsPage.tsx @@ -60,7 +60,7 @@ export const PromptsPage: React.FC = () => { try { const data = await api.webui.get(`/api/v1/prompts/groups/${loaderData.promptId}`); setEditingGroup(data); - setBuilderInitialMode("manual"); + setBuilderInitialMode("ai-assisted"); // Always start in AI-assisted mode for editing setShowBuilder(true); } catch (error) { displayError({ title: "Failed to Edit Prompt", error: getErrorMessage(error, "An error occurred while fetching prompt.") }); diff --git a/client/webui/frontend/src/lib/components/projects/AddProjectFilesDialog.tsx b/client/webui/frontend/src/lib/components/projects/AddProjectFilesDialog.tsx index 82aa7196e..ce317c8f2 100644 --- a/client/webui/frontend/src/lib/components/projects/AddProjectFilesDialog.tsx +++ b/client/webui/frontend/src/lib/components/projects/AddProjectFilesDialog.tsx @@ -1,10 +1,8 @@ import React, { useState, useCallback, useEffect } from "react"; -import { FileText } from "lucide-react"; -import { Button, Card, Textarea } from "@/lib/components/ui"; -import { CardContent } from "@/lib/components/ui/card"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/lib/components/ui/dialog"; -import { MessageBanner } from "@/lib/components/common"; +import { Textarea } from "@/lib/components/ui"; +import { MessageBanner, ConfirmationDialog } from "@/lib/components/common"; +import { FileLabel } from "../chat/file/FileLabel"; interface AddProjectFilesDialogProps { isOpen: boolean; @@ -38,7 +36,7 @@ export const AddProjectFilesDialog: React.FC = ({ is })); }, []); - const handleConfirmClick = useCallback(() => { + const handleConfirmClick = useCallback(async () => { if (!files) return; const formData = new FormData(); @@ -55,52 +53,43 @@ export const AddProjectFilesDialog: React.FC = ({ is formData.append("fileMetadata", JSON.stringify(metadataPayload)); } - onConfirm(formData); + await onConfirm(formData); }, [files, fileDescriptions, onConfirm]); const fileList = files ? Array.from(files) : []; - return ( - !open && handleClose()}> - - - Upload Files to Project - Add descriptions for each file. This helps Solace Agent Mesh understand the file's purpose. - -
- {error && } - {fileList.length > 0 ? ( -
- {fileList.map((file, index) => ( - - -
- -
-

- {file.name} -

-

{(file.size / 1024).toFixed(1)} KB

-
-
-