From fc32a28464db1f244b5c39d7ee5c38ac04421dbb Mon Sep 17 00:00:00 2001 From: potter Date: Mon, 23 Mar 2026 12:06:51 +0800 Subject: [PATCH] Refactor console web runtime pages and APIs --- apps/aevatar-console-web/README.md | 42 +- apps/aevatar-console-web/config/routes.ts | 134 +- apps/aevatar-console-web/package.json | 4 - apps/aevatar-console-web/scripts/dev-stack.sh | 294 -- .../scripts/start-frontend-detached.mjs | 32 - apps/aevatar-console-web/src/app.tsx | 78 +- .../src/pages/actors/actorPresentation.ts | 51 +- .../src/pages/actors/index.test.tsx | 44 + .../src/pages/actors/index.tsx | 621 +-- .../src/pages/governance/activation.tsx | 181 + .../src/pages/governance/bindings.tsx | 107 + .../components/GovernanceQueryCard.tsx | 104 + .../pages/governance/components/columns.tsx | 108 + .../components/governanceQuery.test.ts | 59 + .../governance/components/governanceQuery.ts | 74 + .../src/pages/governance/endpoints.tsx | 108 + .../src/pages/governance/policies.tsx | 107 + .../src/pages/observability/index.test.tsx | 35 + .../src/pages/observability/index.tsx | 249 +- .../src/pages/overview/index.test.tsx | 57 +- .../src/pages/overview/index.tsx | 958 +++-- .../src/pages/overview/useOverviewData.ts | 187 + .../src/pages/playground/index.test.tsx | 58 - .../src/pages/playground/index.tsx | 97 - .../src/pages/primitives/index.test.tsx | 48 + .../src/pages/primitives/index.tsx | 221 +- .../src/pages/runs/index.test.tsx | 84 + .../src/pages/runs/index.tsx | 1245 ++---- .../src/pages/runs/runWorkbenchConfig.tsx | 613 +++ .../scopes/components/ScopeQueryCard.tsx | 83 + .../scopes/components/renderMultilineText.tsx | 24 + .../scopes/components/resolvedScope.test.ts | 79 + .../pages/scopes/components/resolvedScope.ts | 38 + .../scopes/components/scopeQuery.test.ts | 33 + .../src/pages/scopes/components/scopeQuery.ts | 51 + .../src/pages/scopes/scripts.tsx | 318 ++ .../src/pages/scopes/workflows.tsx | 297 ++ .../services/components/ServiceQueryCard.tsx | 78 + .../services/components/serviceQuery.test.ts | 58 + .../pages/services/components/serviceQuery.ts | 89 + .../src/pages/services/detail.tsx | 486 +++ .../src/pages/services/index.tsx | 273 ++ .../src/pages/settings/console.test.tsx | 44 + .../src/pages/settings/console.tsx | 493 +++ .../src/pages/settings/index.tsx | 3636 ----------------- .../{index.test.tsx => runtime.test.tsx} | 315 +- .../src/pages/settings/runtime.tsx | 3 + .../pages/settings/runtimeSettingsPage.tsx | 1839 +++++++++ .../pages/settings/runtimeSettingsShared.tsx | 135 + .../runtimeSettingsWorkspaceSections.tsx | 1489 +++++++ .../studio/components/StudioShell.test.tsx | 75 +- .../components/StudioWorkbenchSections.tsx | 96 +- .../src/pages/studio/index.test.tsx | 1252 +++--- .../src/pages/workflows/index.test.tsx | 22 +- .../src/pages/workflows/index.tsx | 789 ++-- .../pages/workflows/workflowPresentation.ts | 52 +- .../src/pages/yaml/index.test.tsx | 59 - .../src/pages/yaml/index.tsx | 109 - .../src/shared/api/configurationApi.ts | 296 +- .../src/shared/api/configurationDecoders.ts | 556 +++ .../src/shared/api/consoleApi.test.ts | 202 - .../src/shared/api/consoleApi.ts | 374 -- .../src/shared/api/decodeUtils.ts | 116 + .../src/shared/api/decoders.ts | 1317 +----- .../src/shared/api/governanceApi.ts | 535 +++ .../src/shared/api/http/client.ts | 84 + .../src/shared/api/http/decoders.ts | 184 + .../src/shared/api/runtimeActorsApi.ts | 99 + .../src/shared/api/runtimeCatalogApi.ts | 27 + .../src/shared/api/runtimeDecoders.ts | 810 ++++ .../src/shared/api/runtimeQueryApi.ts | 28 + .../src/shared/api/runtimeRunsApi.test.ts | 58 + .../src/shared/api/runtimeRunsApi.ts | 124 + .../src/shared/api/scopesApi.ts | 325 ++ .../src/shared/api/servicesApi.ts | 656 +++ .../src/shared/graphs/buildGraphElements.ts | 47 +- .../src/shared/models/governance.ts | 73 + .../shared/models/platform/configuration.ts | 176 + .../src/shared/models/runtime/actors.ts | 53 + .../src/shared/models/runtime/authoring.ts | 73 + .../src/shared/models/runtime/catalog.ts | 67 + .../src/shared/models/runtime/query.ts | 86 + .../src/shared/models/scopes.ts | 62 + .../src/shared/models/services.ts | 130 + .../src/shared/playground/draftStatus.test.ts | 92 - .../src/shared/playground/draftStatus.ts | 187 - .../src/shared/playground/navigation.test.ts | 24 - .../src/shared/playground/navigation.ts | 37 - .../src/shared/playground/stepSummary.ts | 209 +- .../src/shared/studio/api.ts | 292 +- .../src/shared/workflows/catalogVisibility.ts | 14 +- 91 files changed, 15518 insertions(+), 10210 deletions(-) delete mode 100755 apps/aevatar-console-web/scripts/dev-stack.sh delete mode 100644 apps/aevatar-console-web/scripts/start-frontend-detached.mjs create mode 100644 apps/aevatar-console-web/src/pages/actors/index.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/governance/activation.tsx create mode 100644 apps/aevatar-console-web/src/pages/governance/bindings.tsx create mode 100644 apps/aevatar-console-web/src/pages/governance/components/GovernanceQueryCard.tsx create mode 100644 apps/aevatar-console-web/src/pages/governance/components/columns.tsx create mode 100644 apps/aevatar-console-web/src/pages/governance/components/governanceQuery.test.ts create mode 100644 apps/aevatar-console-web/src/pages/governance/components/governanceQuery.ts create mode 100644 apps/aevatar-console-web/src/pages/governance/endpoints.tsx create mode 100644 apps/aevatar-console-web/src/pages/governance/policies.tsx create mode 100644 apps/aevatar-console-web/src/pages/observability/index.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/overview/useOverviewData.ts delete mode 100644 apps/aevatar-console-web/src/pages/playground/index.test.tsx delete mode 100644 apps/aevatar-console-web/src/pages/playground/index.tsx create mode 100644 apps/aevatar-console-web/src/pages/primitives/index.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/runs/index.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/runs/runWorkbenchConfig.tsx create mode 100644 apps/aevatar-console-web/src/pages/scopes/components/ScopeQueryCard.tsx create mode 100644 apps/aevatar-console-web/src/pages/scopes/components/renderMultilineText.tsx create mode 100644 apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.test.ts create mode 100644 apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.ts create mode 100644 apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.test.ts create mode 100644 apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.ts create mode 100644 apps/aevatar-console-web/src/pages/scopes/scripts.tsx create mode 100644 apps/aevatar-console-web/src/pages/scopes/workflows.tsx create mode 100644 apps/aevatar-console-web/src/pages/services/components/ServiceQueryCard.tsx create mode 100644 apps/aevatar-console-web/src/pages/services/components/serviceQuery.test.ts create mode 100644 apps/aevatar-console-web/src/pages/services/components/serviceQuery.ts create mode 100644 apps/aevatar-console-web/src/pages/services/detail.tsx create mode 100644 apps/aevatar-console-web/src/pages/services/index.tsx create mode 100644 apps/aevatar-console-web/src/pages/settings/console.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/settings/console.tsx delete mode 100644 apps/aevatar-console-web/src/pages/settings/index.tsx rename apps/aevatar-console-web/src/pages/settings/{index.test.tsx => runtime.test.tsx} (51%) create mode 100644 apps/aevatar-console-web/src/pages/settings/runtime.tsx create mode 100644 apps/aevatar-console-web/src/pages/settings/runtimeSettingsPage.tsx create mode 100644 apps/aevatar-console-web/src/pages/settings/runtimeSettingsShared.tsx create mode 100644 apps/aevatar-console-web/src/pages/settings/runtimeSettingsWorkspaceSections.tsx delete mode 100644 apps/aevatar-console-web/src/pages/yaml/index.test.tsx delete mode 100644 apps/aevatar-console-web/src/pages/yaml/index.tsx create mode 100644 apps/aevatar-console-web/src/shared/api/configurationDecoders.ts delete mode 100644 apps/aevatar-console-web/src/shared/api/consoleApi.test.ts delete mode 100644 apps/aevatar-console-web/src/shared/api/consoleApi.ts create mode 100644 apps/aevatar-console-web/src/shared/api/decodeUtils.ts create mode 100644 apps/aevatar-console-web/src/shared/api/governanceApi.ts create mode 100644 apps/aevatar-console-web/src/shared/api/http/client.ts create mode 100644 apps/aevatar-console-web/src/shared/api/http/decoders.ts create mode 100644 apps/aevatar-console-web/src/shared/api/runtimeActorsApi.ts create mode 100644 apps/aevatar-console-web/src/shared/api/runtimeCatalogApi.ts create mode 100644 apps/aevatar-console-web/src/shared/api/runtimeDecoders.ts create mode 100644 apps/aevatar-console-web/src/shared/api/runtimeQueryApi.ts create mode 100644 apps/aevatar-console-web/src/shared/api/runtimeRunsApi.test.ts create mode 100644 apps/aevatar-console-web/src/shared/api/runtimeRunsApi.ts create mode 100644 apps/aevatar-console-web/src/shared/api/scopesApi.ts create mode 100644 apps/aevatar-console-web/src/shared/api/servicesApi.ts create mode 100644 apps/aevatar-console-web/src/shared/models/governance.ts create mode 100644 apps/aevatar-console-web/src/shared/models/platform/configuration.ts create mode 100644 apps/aevatar-console-web/src/shared/models/runtime/actors.ts create mode 100644 apps/aevatar-console-web/src/shared/models/runtime/authoring.ts create mode 100644 apps/aevatar-console-web/src/shared/models/runtime/catalog.ts create mode 100644 apps/aevatar-console-web/src/shared/models/runtime/query.ts create mode 100644 apps/aevatar-console-web/src/shared/models/scopes.ts create mode 100644 apps/aevatar-console-web/src/shared/models/services.ts delete mode 100644 apps/aevatar-console-web/src/shared/playground/draftStatus.test.ts delete mode 100644 apps/aevatar-console-web/src/shared/playground/draftStatus.ts delete mode 100644 apps/aevatar-console-web/src/shared/playground/navigation.test.ts delete mode 100644 apps/aevatar-console-web/src/shared/playground/navigation.ts diff --git a/apps/aevatar-console-web/README.md b/apps/aevatar-console-web/README.md index 0c12b925..026cd0ae 100644 --- a/apps/aevatar-console-web/README.md +++ b/apps/aevatar-console-web/README.md @@ -20,7 +20,7 @@ cp .env.example .env.local pnpm install ``` -`pnpm dev` reads proxy targets from `.env.local`. If you want `pnpm dev:stack` to use custom ports from the same file, export it into your shell first: +`pnpm dev` reads proxy targets from `.env.local`. If you also want your shell to reuse the same values for manually starting backend processes, export the file first: ```bash cd apps/aevatar-console-web @@ -29,9 +29,9 @@ source .env.local set +a ``` -If you change backend ports for `dev:stack`, also keep `AEVATAR_API_TARGET`, `AEVATAR_CONFIGURATION_API_TARGET`, and `AEVATAR_STUDIO_API_TARGET` aligned with those ports. +If you change backend ports, also keep `AEVATAR_API_TARGET`, `AEVATAR_CONFIGURATION_API_TARGET`, and `AEVATAR_STUDIO_API_TARGET` aligned with those ports. -`pnpm dev:stack` also injects a default Studio app scope of `aevatar` through `Cli__App__ScopeId`, and keeps Studio NyxID login enabled. Chrono-storage backed connector and role catalogs require both the scope and a valid Studio NyxID session. Override the scope with `AEVATAR_CONSOLE_SCOPE_ID` if you need a different scope, or set `AEVATAR_CONSOLE_STUDIO_NYXID_ENABLED=false` only when you intentionally want to disable protected Studio APIs. +When starting the Studio sidecar manually, set `Cli__App__ScopeId=aevatar` and keep `Cli__App__NyxId__Enabled=true` unless you intentionally want to disable protected Studio APIs. Chrono-storage backed connector and role catalogs require both the scope and a valid Studio NyxID session. For NyxID login, also set these values in `.env.local`: @@ -50,9 +50,6 @@ If you change `.env.local`, restart `pnpm dev` so Umi reloads the injected env v ```bash cd apps/aevatar-console-web pnpm dev -pnpm dev:stack -pnpm dev:stack:status -pnpm dev:stack:stop pnpm build pnpm test pnpm tsc @@ -66,21 +63,23 @@ pnpm tsc - `Configuration API` on `http://127.0.0.1:6688` - `Studio sidecar` on `http://127.0.0.1:6690` -Use the bundled stack script to start all required services from one place: +Start the required services in separate terminals: ```bash -cd apps/aevatar-console-web -pnpm dev:stack -``` +env ASPNETCORE_URLS=http://127.0.0.1:5080 \ + dotnet run --project src/workflow/Aevatar.Workflow.Host.Api + +dotnet run --project tools/Aevatar.Tools.Config -- --port 6688 --no-browser -The script will: +env Cli__App__NyxId__Enabled=true Cli__App__ScopeId=aevatar \ + dotnet run --project tools/Aevatar.Tools.Cli -- app --no-browser --port 6690 --api-base http://127.0.0.1:5080 -- start `src/workflow/Aevatar.Workflow.Host.Api` -- start `tools/Aevatar.Tools.Config` -- start `tools/Aevatar.Tools.Cli -- app --api-base ` with `Cli__App__ScopeId=aevatar` -- keep Studio NyxID login enabled so remote chrono-storage catalog access can reuse the app session -- start `pnpm dev` -- write logs to `apps/aevatar-console-web/.temp/dev-stack/` +cd apps/aevatar-console-web +AEVATAR_API_TARGET=http://127.0.0.1:5080 \ +AEVATAR_CONFIGURATION_API_TARGET=http://127.0.0.1:6688 \ +AEVATAR_STUDIO_API_TARGET=http://127.0.0.1:6690 \ +pnpm dev +``` Current proxy split during local development: @@ -88,15 +87,6 @@ Current proxy split during local development: - `/api/app/*`, `/api/auth/*`, `/api/workspace/*`, `/api/editor/*`, `/api/executions/*`, `/api/roles/*`, `/api/connectors/*`, `/api/settings/*` -> `Studio sidecar` - `/api/configuration/*` -> `Configuration API` -Useful commands: - -```bash -cd apps/aevatar-console-web -pnpm dev:stack:status -pnpm dev:stack:restart -pnpm dev:stack:stop -``` - ## Current scope - `Overview` diff --git a/apps/aevatar-console-web/config/routes.ts b/apps/aevatar-console-web/config/routes.ts index c2ac6ece..39c1de8d 100644 --- a/apps/aevatar-console-web/config/routes.ts +++ b/apps/aevatar-console-web/config/routes.ts @@ -12,78 +12,124 @@ */ export default [ { - path: '/login', - component: './login', + path: "/login", + component: "./login", layout: false, }, { - path: '/auth/callback', - component: './auth/callback', + path: "/auth/callback", + component: "./auth/callback", layout: false, }, { - path: '/overview', - name: 'Overview', - icon: 'dashboard', - component: './overview', + path: "/overview", + name: "Overview", + icon: "dashboard", + component: "./overview", }, { - path: '/workflows', - name: 'Workflows', - icon: 'branches', - component: './workflows', + path: "/scopes", + name: "Scopes", + icon: "cluster", + redirect: "/scopes/workflows", }, { - path: '/studio', - name: 'Studio', - icon: 'project', - component: './studio', + path: "/scopes/workflows", + component: "./scopes/workflows", }, { - path: '/yaml', - component: './yaml', + path: "/scopes/scripts", + component: "./scopes/scripts", }, { - path: '/primitives', - name: 'Primitives', - icon: 'appstore', - component: './primitives', + path: "/services", + name: "Services", + icon: "api", + component: "./services", }, { - path: '/playground', - component: './playground', + path: "/services/:serviceId", + component: "./services/detail", }, { - path: '/runs', - name: 'Runs', - icon: 'playSquare', - component: './runs', + path: "/governance", + name: "Governance", + icon: "safetyCertificate", + redirect: "/governance/bindings", }, { - path: '/actors', - name: 'Actors', - icon: 'apartment', - component: './actors', + path: "/governance/bindings", + component: "./governance/bindings", }, { - path: '/observability', - name: 'Observability', - icon: 'eye', - component: './observability', + path: "/governance/policies", + component: "./governance/policies", }, { - path: '/settings', - name: 'Settings', - icon: 'setting', - component: './settings', + path: "/governance/endpoints", + component: "./governance/endpoints", }, { - path: '/', - redirect: '/overview', + path: "/governance/activation", + component: "./governance/activation", }, { - component: '404', + path: "/workflows", + name: "Runtime Workflows", + icon: "branches", + component: "./workflows", + }, + { + path: "/studio", + name: "Studio", + icon: "project", + component: "./studio", + }, + { + path: "/primitives", + name: "Runtime Primitives", + icon: "appstore", + component: "./primitives", + }, + { + path: "/runs", + name: "Runtime Runs", + icon: "playSquare", + component: "./runs", + }, + { + path: "/actors", + name: "Runtime Explorer", + icon: "apartment", + component: "./actors", + }, + { + path: "/observability", + name: "Observability", + icon: "eye", + component: "./observability", + }, + { + path: "/settings", + name: "Settings", + icon: "setting", + redirect: "/settings/console", + }, + { + path: "/settings/console", + component: "./settings/console", + }, + { + path: "/settings/runtime", + component: "./settings/runtime", + }, + { + path: "/", + redirect: "/overview", + }, + { + component: "404", layout: false, - path: '/*', + path: "/*", }, ]; diff --git a/apps/aevatar-console-web/package.json b/apps/aevatar-console-web/package.json index d903ed39..47639a0b 100644 --- a/apps/aevatar-console-web/package.json +++ b/apps/aevatar-console-web/package.json @@ -8,10 +8,6 @@ "analyze": "cross-env ANALYZE=1 max build", "build": "max build", "dev": "npm run start:dev", - "dev:stack": "bash ./scripts/dev-stack.sh start", - "dev:stack:restart": "bash ./scripts/dev-stack.sh restart", - "dev:stack:status": "bash ./scripts/dev-stack.sh status", - "dev:stack:stop": "bash ./scripts/dev-stack.sh stop", "jest": "jest", "lint": "npm run biome:lint && npm run tsc", "biome:lint": "npx @biomejs/biome lint", diff --git a/apps/aevatar-console-web/scripts/dev-stack.sh b/apps/aevatar-console-web/scripts/dev-stack.sh deleted file mode 100755 index e9bce6a0..00000000 --- a/apps/aevatar-console-web/scripts/dev-stack.sh +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -REPO_ROOT="$(cd "${APP_DIR}/../.." && pwd)" -RUNTIME_DIR="${APP_DIR}/.temp/dev-stack" - -API_PORT="${AEVATAR_CONSOLE_API_PORT:-5080}" -CONFIG_PORT="${AEVATAR_CONSOLE_CONFIG_PORT:-6688}" -STUDIO_PORT="${AEVATAR_CONSOLE_STUDIO_PORT:-6690}" -FRONTEND_PORT="${AEVATAR_CONSOLE_FRONTEND_PORT:-5173}" -APP_SCOPE_ID="${AEVATAR_CONSOLE_SCOPE_ID:-aevatar}" -STUDIO_NYXID_ENABLED="${AEVATAR_CONSOLE_STUDIO_NYXID_ENABLED:-true}" - -API_URL="http://127.0.0.1:${API_PORT}" -CONFIG_URL="http://127.0.0.1:${CONFIG_PORT}" -STUDIO_URL="http://127.0.0.1:${STUDIO_PORT}" -FRONTEND_URL="http://127.0.0.1:${FRONTEND_PORT}" - -API_PROJECT="${REPO_ROOT}/src/workflow/Aevatar.Workflow.Host.Api" -CONFIG_PROJECT="${REPO_ROOT}/tools/Aevatar.Tools.Config" -STUDIO_PROJECT="${REPO_ROOT}/tools/Aevatar.Tools.Cli" - -mkdir -p "${RUNTIME_DIR}" - -usage() { - cat </dev/null | tail -n +2 || true -} - -service_pid() { - local port - port="$(service_port "$1")" - lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | head -n 1 || true -} - -is_pid_running() { - local pid="$1" - [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null -} - -is_service_listening() { - local name="$1" - [[ -n "$(service_pid "${name}")" ]] -} - -wait_for_url() { - local url="$1" - local attempts="$2" - local delay_seconds="$3" - local attempt - - for (( attempt=1; attempt<=attempts; attempt+=1 )); do - if curl -fsS "${url}" >/dev/null 2>&1; then - return 0 - fi - sleep "${delay_seconds}" - done - - return 1 -} - -start_service() { - local name="$1" - local log_file pid_file pid url - log_file="$(service_log_file "${name}")" - pid_file="$(service_pid_file "${name}")" - url="$(service_url "${name}")" - - if is_service_listening "${name}"; then - echo "[skip] ${name} already listening" - service_process_line "${name}" - return 0 - fi - - rm -f "${pid_file}" - echo "[start] ${name} -> ${log_file}" - - case "${name}" in - api) - nohup env ASPNETCORE_URLS="${API_URL}" \ - dotnet run --project "${API_PROJECT}" >"${log_file}" 2>&1 & - ;; - config) - nohup dotnet run --project "${CONFIG_PROJECT}" -- --port "${CONFIG_PORT}" --no-browser \ - >"${log_file}" 2>&1 & - ;; - studio) - nohup env Cli__App__NyxId__Enabled="${STUDIO_NYXID_ENABLED}" \ - Cli__App__ScopeId="${APP_SCOPE_ID}" \ - dotnet run --project "${STUDIO_PROJECT}" -- app --no-browser --port "${STUDIO_PORT}" --api-base "${API_URL}" \ - >"${log_file}" 2>&1 & - ;; - frontend) - pid="$( - AEVATAR_API_TARGET="${API_URL}" \ - AEVATAR_CONFIGURATION_API_TARGET="${CONFIG_URL}" \ - AEVATAR_STUDIO_API_TARGET="${STUDIO_URL}" \ - node "${APP_DIR}/scripts/start-frontend-detached.mjs" "${log_file}" - )" - echo "${pid}" >"${pid_file}" - ;; - *) - echo "[error] unknown service: ${name}" >&2 - return 1 - ;; - esac - - if [[ "${name}" != "frontend" ]]; then - pid=$! - echo "${pid}" >"${pid_file}" - fi - - if ! wait_for_url "${url}" 120 1; then - echo "[error] ${name} did not become ready: ${url}" >&2 - if [[ -f "${pid_file}" ]]; then - pid="$(cat "${pid_file}")" - if is_pid_running "${pid}"; then - kill "${pid}" 2>/dev/null || true - fi - rm -f "${pid_file}" - fi - echo "[hint] inspect log: ${log_file}" >&2 - return 1 - fi - - pid="$(service_pid "${name}")" - if [[ -n "${pid}" ]]; then - echo "${pid}" >"${pid_file}" - fi - - echo "[ready] ${name} -> ${url}" -} - -stop_service() { - local name="$1" - local pid_file pid observed_pid - pid_file="$(service_pid_file "${name}")" - - pid="" - if [[ -f "${pid_file}" ]]; then - pid="$(cat "${pid_file}")" - fi - - if is_pid_running "${pid}"; then - echo "[stop] ${name} pid=${pid}" - kill "${pid}" 2>/dev/null || true - else - observed_pid="$(service_pid "${name}")" - if [[ -n "${observed_pid}" ]]; then - echo "[skip] ${name} is listening on port $(service_port "${name}") but was not started by this script" - else - echo "[skip] ${name} is not running" - fi - rm -f "${pid_file}" - return 0 - fi - - for _ in $(seq 1 30); do - if ! is_pid_running "${pid}"; then - break - fi - sleep 1 - done - - if is_pid_running "${pid}"; then - echo "[warn] ${name} did not exit in time, sending SIGKILL" - kill -9 "${pid}" 2>/dev/null || true - fi - - rm -f "${pid_file}" -} - -status_service() { - local name="$1" - local pid_file pid - pid_file="$(service_pid_file "${name}")" - pid="$(service_pid "${name}")" - - if [[ -n "${pid}" ]]; then - echo "[up] ${name} port=$(service_port "${name}") pid=${pid}" - service_process_line "${name}" - elif [[ -f "${pid_file}" ]]; then - echo "[down] ${name} stale-pid=$(cat "${pid_file}")" - else - echo "[down] ${name}" - fi -} - -command="${1:-start}" - -case "${command}" in - start) - start_service api - start_service config - start_service studio - start_service frontend - echo - echo "Console URLs:" - echo " frontend: ${FRONTEND_URL}" - echo " api: ${API_URL}" - echo " config: ${CONFIG_URL}" - echo " studio: ${STUDIO_URL}" - echo " scope: ${APP_SCOPE_ID}" - echo " nyxid: ${STUDIO_NYXID_ENABLED}" - echo - echo "Logs:" - echo " ${RUNTIME_DIR}/api.log" - echo " ${RUNTIME_DIR}/config.log" - echo " ${RUNTIME_DIR}/studio.log" - echo " ${RUNTIME_DIR}/frontend.log" - ;; - stop) - stop_service frontend - stop_service studio - stop_service config - stop_service api - ;; - restart) - stop_service frontend - stop_service studio - stop_service config - stop_service api - start_service api - start_service config - start_service studio - start_service frontend - ;; - status) - status_service api - status_service config - status_service studio - status_service frontend - ;; - -h|--help|help) - usage - ;; - *) - echo "[error] unknown command: ${command}" >&2 - usage >&2 - exit 1 - ;; -esac diff --git a/apps/aevatar-console-web/scripts/start-frontend-detached.mjs b/apps/aevatar-console-web/scripts/start-frontend-detached.mjs deleted file mode 100644 index b6406e2d..00000000 --- a/apps/aevatar-console-web/scripts/start-frontend-detached.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { openSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawn } from 'node:child_process'; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const appDir = resolve(scriptDir, '..'); -const logFile = process.argv[2]; - -if (!logFile) { - console.error('Usage: node scripts/start-frontend-detached.mjs '); - process.exit(1); -} - -const outputFd = openSync(logFile, 'a'); -const child = spawn('pnpm', ['dev'], { - cwd: appDir, - detached: true, - env: { - ...process.env, - PORT: - process.env.PORT || - process.env.AEVATAR_CONSOLE_FRONTEND_PORT || - '5173', - UMI_ENV: 'dev', - MOCK: 'none', - }, - stdio: ['ignore', outputFd, outputFd], -}); - -child.unref(); -console.log(String(child.pid)); diff --git a/apps/aevatar-console-web/src/app.tsx b/apps/aevatar-console-web/src/app.tsx index 5f69acea..feed5604 100644 --- a/apps/aevatar-console-web/src/app.tsx +++ b/apps/aevatar-console-web/src/app.tsx @@ -2,38 +2,38 @@ import { enUSIntl, PageLoading, ProConfigProvider, -} from '@ant-design/pro-components'; +} from "@ant-design/pro-components"; import { DownOutlined, LogoutOutlined, SettingOutlined, UserOutlined, -} from '@ant-design/icons'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { Avatar, ConfigProvider, Dropdown, Space, Typography } from 'antd'; -import enUS from 'antd/locale/en_US'; -import React from 'react'; -import { history } from '@umijs/max'; -import BrandLogo from '@/components/BrandLogo'; -import defaultSettings from '../config/defaultSettings'; -import { errorConfig } from './requestErrorConfig'; +} from "@ant-design/icons"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { Avatar, ConfigProvider, Dropdown, Space, Typography } from "antd"; +import enUS from "antd/locale/en_US"; +import React from "react"; +import { history } from "@umijs/max"; +import BrandLogo from "@/components/BrandLogo"; +import defaultSettings from "../config/defaultSettings"; +import { errorConfig } from "./requestErrorConfig"; import { ensureActiveAuthSession, hasRestorableAuthSession, -} from './shared/auth/client'; -import { getNyxIDRuntimeConfig } from './shared/auth/config'; +} from "./shared/auth/client"; +import { getNyxIDRuntimeConfig } from "./shared/auth/config"; import { buildAuthInitialState, clearStoredAuthSession, loadRestorableAuthSession, loadStoredAuthSession, sanitizeReturnTo, -} from './shared/auth/session'; -import { queryClient } from './shared/query/queryClient'; +} from "./shared/auth/session"; +import { queryClient } from "./shared/query/queryClient"; -const PUBLIC_ROUTES = new Set(['/login', '/auth/callback']); -const DEFAULT_PROTECTED_ROUTE = '/overview'; -const STUDIO_HOST_ROUTES = new Set(['/studio', '/playground', '/yaml']); +const PUBLIC_ROUTES = new Set(["/login", "/auth/callback"]); +const DEFAULT_PROTECTED_ROUTE = "/overview"; +const STUDIO_HOST_ROUTES = new Set(["/studio"]); function isStudioHostRoute(pathname: string): boolean { return STUDIO_HOST_ROUTES.has(pathname); @@ -47,7 +47,7 @@ function buildLoginRoute(returnTo: string): string { } function getCurrentReturnTo(pathname: string): string { - return pathname === '/' + return pathname === "/" ? DEFAULT_PROTECTED_ROUTE : `${pathname}${window.location.search}${window.location.hash}`; } @@ -81,7 +81,9 @@ const AuthSessionBootstrap: React.FC = ({ pathname, children, }) => { - const [ready, setReady] = React.useState(() => Boolean(loadStoredAuthSession())); + const [ready, setReady] = React.useState(() => + Boolean(loadStoredAuthSession()) + ); React.useEffect(() => { let cancelled = false; @@ -137,7 +139,7 @@ export const layout = ({ return; } - if (pathname === '/') { + if (pathname === "/") { history.replace(DEFAULT_PROTECTED_ROUTE); } }, @@ -156,43 +158,43 @@ export const layout = ({ menu={{ items: [ { - key: 'settings', + key: "settings", icon: , - label: 'Settings', + label: "Settings", }, { - key: 'logout', + key: "logout", icon: , - label: 'Logout', + label: "Logout", }, ], onClick: ({ key }) => { - if (key === 'settings') { - history.push('/settings'); + if (key === "settings") { + history.push("/settings"); return; } - if (key === 'logout') { + if (key === "logout") { clearStoredAuthSession(); - window.location.replace('/login'); + window.location.replace("/login"); } }, }} placement="bottomRight" - trigger={['click']} + trigger={["click"]} > @@ -204,11 +206,11 @@ export const layout = ({ diff --git a/apps/aevatar-console-web/src/pages/actors/actorPresentation.ts b/apps/aevatar-console-web/src/pages/actors/actorPresentation.ts index 34a3d0ec..fdafd99f 100644 --- a/apps/aevatar-console-web/src/pages/actors/actorPresentation.ts +++ b/apps/aevatar-console-web/src/pages/actors/actorPresentation.ts @@ -3,9 +3,9 @@ import type { WorkflowActorGraphNode, WorkflowActorGraphSubgraph, WorkflowActorTimelineItem, -} from '@/shared/api/models'; +} from "@/shared/models/runtime/actors"; -export type TimelineStatus = 'processing' | 'success' | 'error' | 'default'; +export type TimelineStatus = "processing" | "success" | "error" | "default"; export type ActorTimelineRow = WorkflowActorTimelineItem & { key: string; @@ -24,24 +24,24 @@ export type ActorTimelineFilters = { export function deriveTimelineStatus(stage: string): TimelineStatus { const normalized = stage.toLowerCase(); - if (normalized.includes('error') || normalized.includes('failed')) { - return 'error'; + if (normalized.includes("error") || normalized.includes("failed")) { + return "error"; } if ( - normalized.includes('completed') || - normalized.includes('finish') || - normalized.includes('end') + normalized.includes("completed") || + normalized.includes("finish") || + normalized.includes("end") ) { - return 'success'; + return "success"; } if ( - normalized.includes('start') || - normalized.includes('running') || - normalized.includes('wait') + normalized.includes("start") || + normalized.includes("running") || + normalized.includes("wait") ) { - return 'processing'; + return "processing"; } - return 'default'; + return "default"; } function summarizeTimelineData(data: Record): { @@ -51,7 +51,7 @@ function summarizeTimelineData(data: Record): { const entries = Object.entries(data); if (entries.length === 0) { return { - dataSummary: '', + dataSummary: "", dataCount: 0, }; } @@ -59,7 +59,7 @@ function summarizeTimelineData(data: Record): { const preview = entries .slice(0, 2) .map(([key, value]) => `${key}=${value}`) - .join(' · '); + .join(" · "); return { dataSummary: @@ -69,7 +69,7 @@ function summarizeTimelineData(data: Record): { } export function buildTimelineRows( - items: WorkflowActorTimelineItem[], + items: WorkflowActorTimelineItem[] ): ActorTimelineRow[] { return items.map((item, index) => ({ ...item, @@ -81,12 +81,12 @@ export function buildTimelineRows( export function filterTimelineRows( rows: ActorTimelineRow[], - filters: ActorTimelineFilters, + filters: ActorTimelineFilters ): ActorTimelineRow[] { const query = filters.query.trim().toLowerCase(); return rows.filter((row) => { - if (filters.errorsOnly && row.timelineStatus !== 'error') { + if (filters.errorsOnly && row.timelineStatus !== "error") { return false; } @@ -101,7 +101,10 @@ export function filterTimelineRows( return false; } - if (filters.stepTypes.length > 0 && !filters.stepTypes.includes(row.stepType)) { + if ( + filters.stepTypes.length > 0 && + !filters.stepTypes.includes(row.stepType) + ) { return false; } @@ -118,7 +121,7 @@ export function filterTimelineRows( row.eventType, row.dataSummary, ] - .join(' ') + .join(" ") .toLowerCase() .includes(query); }); @@ -126,7 +129,7 @@ export function filterTimelineRows( export function deriveSubgraphFromEdges( edges: WorkflowActorGraphEdge[], - rootNodeId: string, + rootNodeId: string ): WorkflowActorGraphSubgraph { const nodesById = new Map(); @@ -137,11 +140,11 @@ export function deriveSubgraphFromEdges( nodesById.set(nodeId, { nodeId, - nodeType: nodeId === rootNodeId ? 'RootActor' : 'DerivedActor', - updatedAt: '', + nodeType: nodeId === rootNodeId ? "RootActor" : "DerivedActor", + updatedAt: "", properties: { nodeId, - source: 'graph-edges', + source: "graph-edges", }, }); } diff --git a/apps/aevatar-console-web/src/pages/actors/index.test.tsx b/apps/aevatar-console-web/src/pages/actors/index.test.tsx new file mode 100644 index 00000000..af10c53a --- /dev/null +++ b/apps/aevatar-console-web/src/pages/actors/index.test.tsx @@ -0,0 +1,44 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; +import { renderWithQueryClient } from '../../../tests/reactQueryTestUtils'; +import ActorsPage from './index'; + +jest.mock('@/shared/api/runtimeActorsApi', () => ({ + runtimeActorsApi: { + getActorSnapshot: jest.fn(), + getActorTimeline: jest.fn(), + getActorGraphEnriched: jest.fn(), + getActorGraphEdges: jest.fn(), + getActorGraphSubgraph: jest.fn(), + }, +})); + +jest.mock('@/shared/graphs/GraphCanvas', () => ({ + __esModule: true, + default: () => { + const React = require('react'); + return React.createElement('div', null, 'GraphCanvas'); + }, +})); + +describe('ActorsPage', () => { + it('renders the runtime explorer shell and navigation actions', async () => { + const { container } = renderWithQueryClient( + React.createElement(ActorsPage), + ); + + expect(container.textContent).toContain('Runtime Explorer'); + expect(container.textContent).toContain( + 'Inspect runtime actor snapshots, filter execution history, and switch across enriched, subgraph, and edges-only topology views.', + ); + expect(screen.getByRole('button', { name: 'Open runs' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Open workflows' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: 'Open observability' }), + ).toBeTruthy(); + expect(container.textContent).toContain('Runtime actor query'); + expect(container.textContent).toContain( + 'Provide a runtime actorId to load actor data.', + ); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/actors/index.tsx b/apps/aevatar-console-web/src/pages/actors/index.tsx index b2a34adf..a29cccdd 100644 --- a/apps/aevatar-console-web/src/pages/actors/index.tsx +++ b/apps/aevatar-console-web/src/pages/actors/index.tsx @@ -2,7 +2,7 @@ import type { ProColumns, ProDescriptionsItemProps, ProFormInstance, -} from '@ant-design/pro-components'; +} from "@ant-design/pro-components"; import { PageContainer, ProCard, @@ -13,8 +13,9 @@ import { ProFormSelect, ProFormText, ProTable, -} from '@ant-design/pro-components'; -import { useQuery } from '@tanstack/react-query'; +} from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; import { Alert, Button, @@ -26,22 +27,22 @@ import { Statistic, Tag, Typography, -} from 'antd'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { consoleApi } from '@/shared/api/consoleApi'; +} from "antd"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; import type { WorkflowActorGraphEdge, WorkflowActorGraphNode, WorkflowActorGraphSubgraph, WorkflowActorSnapshot, -} from '@/shared/api/models'; -import { formatDateTime } from '@/shared/datetime/dateTime'; -import { buildActorGraphElements } from '@/shared/graphs/buildGraphElements'; -import GraphCanvas from '@/shared/graphs/GraphCanvas'; +} from "@/shared/models/runtime/actors"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import { buildActorGraphElements } from "@/shared/graphs/buildGraphElements"; +import GraphCanvas from "@/shared/graphs/GraphCanvas"; import { type ActorGraphDirection, loadConsolePreferences, -} from '@/shared/preferences/consolePreferences'; +} from "@/shared/preferences/consolePreferences"; import { cardStackStyle, compactTableCardProps, @@ -49,16 +50,16 @@ import { fillCardStyle, moduleCardProps, stretchColumnStyle, -} from '@/shared/ui/proComponents'; +} from "@/shared/ui/proComponents"; import { type ActorTimelineFilters, type ActorTimelineRow, buildTimelineRows, deriveSubgraphFromEdges, filterTimelineRows, -} from './actorPresentation'; +} from "./actorPresentation"; -type ActorGraphViewMode = 'enriched' | 'subgraph' | 'edges'; +type ActorGraphViewMode = "enriched" | "subgraph" | "edges"; type ActorPageState = { actorId: string; @@ -70,7 +71,7 @@ type ActorPageState = { }; type ActorSnapshotRecord = WorkflowActorSnapshot & { - executionStatus: 'success' | 'error' | 'default'; + executionStatus: "success" | "error" | "default"; completionRate: number; }; @@ -103,47 +104,47 @@ const defaultTimelineFilters: ActorTimelineFilters = { stages: [], eventTypes: [], stepTypes: [], - query: '', + query: "", errorsOnly: false, }; const graphViewOptions: Array<{ label: string; value: ActorGraphViewMode }> = [ - { label: 'Enriched', value: 'enriched' }, - { label: 'Subgraph', value: 'subgraph' }, - { label: 'Edges only', value: 'edges' }, + { label: "Enriched", value: "enriched" }, + { label: "Subgraph", value: "subgraph" }, + { label: "Edges only", value: "edges" }, ]; const executionValueEnum = { - success: { text: 'Healthy', status: 'Success' }, - error: { text: 'Error', status: 'Error' }, - default: { text: 'Unknown', status: 'Default' }, + success: { text: "Healthy", status: "Success" }, + error: { text: "Error", status: "Error" }, + default: { text: "Unknown", status: "Default" }, } as const; const timelineStatusValueEnum = { - processing: { text: 'Processing', status: 'Processing' }, - success: { text: 'Completed', status: 'Success' }, - error: { text: 'Error', status: 'Error' }, - default: { text: 'Observed', status: 'Default' }, + processing: { text: "Processing", status: "Processing" }, + success: { text: "Completed", status: "Success" }, + error: { text: "Error", status: "Error" }, + default: { text: "Observed", status: "Default" }, } as const; const graphViewLabels: Record = { - enriched: 'Enriched snapshot', - subgraph: 'Subgraph', - edges: 'Edges only', + enriched: "Backend enriched snapshot", + subgraph: "Subgraph", + edges: "Edges only", }; function renderPropertyList(properties: Record) { const entries = Object.entries(properties); if (entries.length === 0) { - return 'n/a'; + return "n/a"; } return ( - + {entries.map(([key, value]) => ( - {key}:{' '} - {value || 'n/a'} + {key}:{" "} + {value || "n/a"} ))} @@ -152,208 +153,210 @@ function renderPropertyList(properties: Record) { const snapshotColumns: ProDescriptionsItemProps[] = [ { - title: 'ActorId', - dataIndex: 'actorId', + title: "ActorId", + dataIndex: "actorId", render: (_, record) => ( {record.actorId} ), }, { - title: 'Workflow', - dataIndex: 'workflowName', + title: "Workflow", + dataIndex: "workflowName", }, { - title: 'Execution', - dataIndex: 'executionStatus', - valueType: 'status' as any, + title: "Execution", + dataIndex: "executionStatus", + valueType: "status" as any, valueEnum: executionValueEnum, }, { - title: 'Completion', - dataIndex: 'completionRate', - valueType: 'percent', + title: "Completion", + dataIndex: "completionRate", + valueType: "percent", }, { - title: 'State version', - dataIndex: 'stateVersion', - valueType: 'digit', + title: "State version", + dataIndex: "stateVersion", + valueType: "digit", }, { - title: 'Role replies', - dataIndex: 'roleReplyCount', - valueType: 'digit', + title: "Role replies", + dataIndex: "roleReplyCount", + valueType: "digit", }, { - title: 'Last command', - dataIndex: 'lastCommandId', + title: "Last command", + dataIndex: "lastCommandId", render: (_, record) => record.lastCommandId ? ( {record.lastCommandId} ) : ( - 'n/a' + "n/a" ), }, { - title: 'Last updated', - dataIndex: 'lastUpdatedAt', - valueType: 'dateTime', + title: "Last updated", + dataIndex: "lastUpdatedAt", + valueType: "dateTime", render: (_, record) => formatDateTime(record.lastUpdatedAt), }, { - title: 'Last output', - dataIndex: 'lastOutput', - render: (_, record) => record.lastOutput || 'n/a', + title: "Last output", + dataIndex: "lastOutput", + render: (_, record) => record.lastOutput || "n/a", }, { - title: 'Last error', - dataIndex: 'lastError', - render: (_, record) => record.lastError || 'n/a', + title: "Last error", + dataIndex: "lastError", + render: (_, record) => record.lastError || "n/a", }, ]; const timelineColumns: ProColumns[] = [ { - title: 'Timestamp', - dataIndex: 'timestamp', - valueType: 'dateTime', + title: "Timestamp", + dataIndex: "timestamp", + valueType: "dateTime", width: 220, render: (_, record) => formatDateTime(record.timestamp), }, { - title: 'Status', - dataIndex: 'timelineStatus', - valueType: 'status' as any, + title: "Status", + dataIndex: "timelineStatus", + valueType: "status" as any, valueEnum: timelineStatusValueEnum, width: 120, }, { - title: 'Stage', - dataIndex: 'stage', + title: "Stage", + dataIndex: "stage", width: 180, }, { - title: 'Event type', - dataIndex: 'eventType', + title: "Event type", + dataIndex: "eventType", width: 220, - render: (_, record) => record.eventType || 'n/a', + render: (_, record) => record.eventType || "n/a", }, { - title: 'Message', - dataIndex: 'message', + title: "Message", + dataIndex: "message", ellipsis: true, }, { - title: 'Step', - dataIndex: 'stepId', + title: "Step", + dataIndex: "stepId", width: 180, - render: (_, record) => record.stepId || 'n/a', + render: (_, record) => record.stepId || "n/a", }, { - title: 'Step type', - dataIndex: 'stepType', + title: "Step type", + dataIndex: "stepType", width: 160, - render: (_, record) => record.stepType || 'n/a', + render: (_, record) => record.stepType || "n/a", }, { - title: 'Actor', - dataIndex: 'agentId', + title: "Actor", + dataIndex: "agentId", width: 200, - render: (_, record) => record.agentId || 'n/a', + render: (_, record) => record.agentId || "n/a", }, { - title: 'Data', - dataIndex: 'dataSummary', + title: "Data", + dataIndex: "dataSummary", ellipsis: true, render: (_, record) => record.dataCount > 0 - ? `${record.dataCount} field${record.dataCount === 1 ? '' : 's'} · ${record.dataSummary}` - : 'n/a', + ? `${record.dataCount} field${record.dataCount === 1 ? "" : "s"} · ${ + record.dataSummary + }` + : "n/a", }, ]; const graphSummaryColumns: ProDescriptionsItemProps[] = [ { - title: 'View', - dataIndex: 'mode', + title: "View", + dataIndex: "mode", render: (_, record) => graphViewLabels[record.mode], }, { - title: 'Direction', - dataIndex: 'direction', + title: "Direction", + dataIndex: "direction", }, { - title: 'Depth', - dataIndex: 'depth', - valueType: 'digit', + title: "Depth", + dataIndex: "depth", + valueType: "digit", }, { - title: 'Take', - dataIndex: 'take', - valueType: 'digit', + title: "Take", + dataIndex: "take", + valueType: "digit", }, { - title: 'Edge types', - dataIndex: 'edgeTypes', + title: "Edge types", + dataIndex: "edgeTypes", }, { - title: 'Root node', - dataIndex: 'rootNodeId', + title: "Root node", + dataIndex: "rootNodeId", render: (_, record) => ( {record.rootNodeId} ), }, { - title: 'Nodes', - dataIndex: 'nodeCount', - valueType: 'digit', + title: "Nodes", + dataIndex: "nodeCount", + valueType: "digit", }, { - title: 'Edges', - dataIndex: 'edgeCount', - valueType: 'digit', + title: "Edges", + dataIndex: "edgeCount", + valueType: "digit", }, ]; const nodeDetailColumns: ProDescriptionsItemProps[] = [ { - title: 'NodeId', - dataIndex: 'nodeId', + title: "NodeId", + dataIndex: "nodeId", render: (_, record) => ( {record.nodeId} ), }, { - title: 'Primary label', - dataIndex: 'primaryLabel', + title: "Primary label", + dataIndex: "primaryLabel", }, { - title: 'Node type', - dataIndex: 'nodeType', + title: "Node type", + dataIndex: "nodeType", }, { - title: 'Role', - dataIndex: ['properties', 'role'], - render: (_, record) => record.properties.role || 'n/a', + title: "Role", + dataIndex: ["properties", "role"], + render: (_, record) => record.properties.role || "n/a", }, { - title: 'Updated at', - dataIndex: 'updatedAt', + title: "Updated at", + dataIndex: "updatedAt", render: (_, record) => formatDateTime(record.updatedAt), }, { - title: 'Root node', - dataIndex: 'isRoot', - render: (_, record) => (record.isRoot ? 'Yes' : 'No'), + title: "Root node", + dataIndex: "isRoot", + render: (_, record) => (record.isRoot ? "Yes" : "No"), }, { - title: 'Property count', - dataIndex: 'propertyCount', - valueType: 'digit', + title: "Property count", + dataIndex: "propertyCount", + valueType: "digit", }, { - title: 'Properties', - dataIndex: 'properties', + title: "Properties", + dataIndex: "properties", span: 2, render: (_, record) => renderPropertyList(record.properties), }, @@ -361,43 +364,43 @@ const nodeDetailColumns: ProDescriptionsItemProps[] = [ const edgeDetailColumns: ProDescriptionsItemProps[] = [ { - title: 'EdgeId', - dataIndex: 'edgeId', + title: "EdgeId", + dataIndex: "edgeId", render: (_, record) => ( {record.edgeId} ), }, { - title: 'Type', - dataIndex: 'edgeType', + title: "Type", + dataIndex: "edgeType", }, { - title: 'From', - dataIndex: 'fromNodeId', + title: "From", + dataIndex: "fromNodeId", render: (_, record) => ( {record.fromNodeId} ), }, { - title: 'To', - dataIndex: 'toNodeId', + title: "To", + dataIndex: "toNodeId", render: (_, record) => ( {record.toNodeId} ), }, { - title: 'Updated at', - dataIndex: 'updatedAt', + title: "Updated at", + dataIndex: "updatedAt", render: (_, record) => formatDateTime(record.updatedAt), }, { - title: 'Property count', - dataIndex: 'propertyCount', - valueType: 'digit', + title: "Property count", + dataIndex: "propertyCount", + valueType: "digit", }, { - title: 'Properties', - dataIndex: 'properties', + title: "Properties", + dataIndex: "properties", span: 2, render: (_, record) => renderPropertyList(record.properties), }, @@ -418,9 +421,9 @@ function parsePositiveInt(value: string | null, fallback: number): number { function parseDirection( value: string | null, - fallback: ActorGraphDirection, + fallback: ActorGraphDirection ): ActorGraphDirection { - if (value === 'Both' || value === 'Outbound' || value === 'Inbound') { + if (value === "Both" || value === "Outbound" || value === "Inbound") { return value; } @@ -428,18 +431,18 @@ function parseDirection( } function parseGraphViewMode(value: string | null): ActorGraphViewMode { - if (value === 'subgraph' || value === 'edges' || value === 'enriched') { + if (value === "subgraph" || value === "edges" || value === "enriched") { return value; } - return 'enriched'; + return "enriched"; } function readStateFromUrl(): ActorPageState { const preferences = loadConsolePreferences(); - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return { - actorId: '', + actorId: "", timelineTake: preferences.actorTimelineTake, graphDepth: preferences.actorGraphDepth, graphTake: preferences.actorGraphTake, @@ -450,37 +453,37 @@ function readStateFromUrl(): ActorPageState { const params = new URLSearchParams(window.location.search); return { - actorId: params.get('actorId') ?? '', + actorId: params.get("actorId") ?? "", timelineTake: parsePositiveInt( - params.get('timelineTake'), - preferences.actorTimelineTake, + params.get("timelineTake"), + preferences.actorTimelineTake ), graphDepth: parsePositiveInt( - params.get('graphDepth'), - preferences.actorGraphDepth, + params.get("graphDepth"), + preferences.actorGraphDepth ), graphTake: parsePositiveInt( - params.get('graphTake'), - preferences.actorGraphTake, + params.get("graphTake"), + preferences.actorGraphTake ), graphDirection: parseDirection( - params.get('graphDirection'), - preferences.actorGraphDirection, + params.get("graphDirection"), + preferences.actorGraphDirection ), edgeTypes: params - .getAll('edgeTypes') + .getAll("edgeTypes") .map((value) => value.trim()) .filter(Boolean), }; } function readGraphViewModeFromUrl(): ActorGraphViewMode { - if (typeof window === 'undefined') { - return 'enriched'; + if (typeof window === "undefined") { + return "enriched"; } return parseGraphViewMode( - new URLSearchParams(window.location.search).get('graphView'), + new URLSearchParams(window.location.search).get("graphView") ); } @@ -488,7 +491,7 @@ const ActorsPage: React.FC = () => { const initialState = useMemo(() => readStateFromUrl(), []); const initialGraphViewMode = useMemo(() => readGraphViewModeFromUrl(), []); const formRef = useRef | undefined>( - undefined, + undefined ); const timelineFormRef = useRef< ProFormInstance | undefined @@ -500,39 +503,39 @@ const ActorsPage: React.FC = () => { const [graphViewMode, setGraphViewMode] = useState(initialGraphViewMode); const [timelineFilters, setTimelineFilters] = useState( - defaultTimelineFilters, + defaultTimelineFilters ); - const [selectedNodeId, setSelectedNodeId] = useState(''); - const [selectedEdgeId, setSelectedEdgeId] = useState(''); - const [selectedTimelineKey, setSelectedTimelineKey] = useState(''); + const [selectedNodeId, setSelectedNodeId] = useState(""); + const [selectedEdgeId, setSelectedEdgeId] = useState(""); + const [selectedTimelineKey, setSelectedTimelineKey] = useState(""); const snapshotQuery = useQuery({ - queryKey: ['actor-snapshot', filters.actorId], + queryKey: ["actor-snapshot", filters.actorId], enabled: Boolean(filters.actorId), - queryFn: () => consoleApi.getActorSnapshot(filters.actorId), + queryFn: () => runtimeActorsApi.getActorSnapshot(filters.actorId), }); const timelineQuery = useQuery({ - queryKey: ['actor-timeline', filters.actorId, filters.timelineTake], + queryKey: ["actor-timeline", filters.actorId, filters.timelineTake], enabled: Boolean(filters.actorId), queryFn: () => - consoleApi.getActorTimeline(filters.actorId, { + runtimeActorsApi.getActorTimeline(filters.actorId, { take: filters.timelineTake, }), }); const graphEnrichedQuery = useQuery({ queryKey: [ - 'actor-graph-enriched', + "actor-graph-enriched", filters.actorId, filters.graphDepth, filters.graphTake, filters.graphDirection, - [...filters.edgeTypes].sort().join(','), + [...filters.edgeTypes].sort().join(","), ], enabled: Boolean(filters.actorId), queryFn: () => - consoleApi.getActorGraphEnriched(filters.actorId, { + runtimeActorsApi.getActorGraphEnriched(filters.actorId, { depth: filters.graphDepth, take: filters.graphTake, direction: filters.graphDirection, @@ -542,16 +545,16 @@ const ActorsPage: React.FC = () => { const graphSubgraphQuery = useQuery({ queryKey: [ - 'actor-graph-subgraph', + "actor-graph-subgraph", filters.actorId, filters.graphDepth, filters.graphTake, filters.graphDirection, - [...filters.edgeTypes].sort().join(','), + [...filters.edgeTypes].sort().join(","), ], - enabled: Boolean(filters.actorId) && graphViewMode === 'subgraph', + enabled: Boolean(filters.actorId) && graphViewMode === "subgraph", queryFn: () => - consoleApi.getActorGraphSubgraph(filters.actorId, { + runtimeActorsApi.getActorGraphSubgraph(filters.actorId, { depth: filters.graphDepth, take: filters.graphTake, direction: filters.graphDirection, @@ -561,15 +564,15 @@ const ActorsPage: React.FC = () => { const graphEdgesQuery = useQuery({ queryKey: [ - 'actor-graph-edges', + "actor-graph-edges", filters.actorId, filters.graphTake, filters.graphDirection, - [...filters.edgeTypes].sort().join(','), + [...filters.edgeTypes].sort().join(","), ], - enabled: Boolean(filters.actorId) && graphViewMode === 'edges', + enabled: Boolean(filters.actorId) && graphViewMode === "edges", queryFn: () => - consoleApi.getActorGraphEdges(filters.actorId, { + runtimeActorsApi.getActorGraphEdges(filters.actorId, { take: filters.graphTake, direction: filters.graphDirection, edgeTypes: filters.edgeTypes, @@ -585,10 +588,10 @@ const ActorsPage: React.FC = () => { ...snapshotQuery.data, executionStatus: snapshotQuery.data.lastSuccess === null - ? 'default' + ? "default" : snapshotQuery.data.lastSuccess - ? 'success' - : 'error', + ? "success" + : "error", completionRate: snapshotQuery.data.totalSteps > 0 ? snapshotQuery.data.completedSteps / snapshotQuery.data.totalSteps @@ -598,17 +601,17 @@ const ActorsPage: React.FC = () => { const timelineRows = useMemo( () => buildTimelineRows(timelineQuery.data ?? []), - [timelineQuery.data], + [timelineQuery.data] ); const filteredTimelineRows = useMemo( () => filterTimelineRows(timelineRows, timelineFilters), - [timelineRows, timelineFilters], + [timelineRows, timelineFilters] ); const selectedTimelineRecord = useMemo( () => timelineRows.find((row) => row.key === selectedTimelineKey), - [selectedTimelineKey, timelineRows], + [selectedTimelineKey, timelineRows] ); const timelineStageOptions = useMemo( @@ -616,27 +619,27 @@ const ActorsPage: React.FC = () => { Array.from(new Set(timelineRows.map((row) => row.stage).filter(Boolean))) .sort((left, right) => left.localeCompare(right)) .map((value) => ({ label: value, value })), - [timelineRows], + [timelineRows] ); const timelineEventTypeOptions = useMemo( () => Array.from( - new Set(timelineRows.map((row) => row.eventType).filter(Boolean)), + new Set(timelineRows.map((row) => row.eventType).filter(Boolean)) ) .sort((left, right) => left.localeCompare(right)) .map((value) => ({ label: value, value })), - [timelineRows], + [timelineRows] ); const timelineStepTypeOptions = useMemo( () => Array.from( - new Set(timelineRows.map((row) => row.stepType).filter(Boolean)), + new Set(timelineRows.map((row) => row.stepType).filter(Boolean)) ) .sort((left, right) => left.localeCompare(right)) .map((value) => ({ label: value, value })), - [timelineRows], + [timelineRows] ); const currentGraph = useMemo(() => { @@ -644,11 +647,11 @@ const ActorsPage: React.FC = () => { return undefined; } - if (graphViewMode === 'subgraph') { + if (graphViewMode === "subgraph") { return graphSubgraphQuery.data; } - if (graphViewMode === 'edges') { + if (graphViewMode === "edges") { return graphEdgesQuery.data ? deriveSubgraphFromEdges(graphEdgesQuery.data, filters.actorId) : undefined; @@ -664,18 +667,18 @@ const ActorsPage: React.FC = () => { ]); const currentGraphLoading = - graphViewMode === 'subgraph' + graphViewMode === "subgraph" ? graphSubgraphQuery.isLoading - : graphViewMode === 'edges' - ? graphEdgesQuery.isLoading - : graphEnrichedQuery.isLoading; + : graphViewMode === "edges" + ? graphEdgesQuery.isLoading + : graphEnrichedQuery.isLoading; const currentGraphError = - graphViewMode === 'subgraph' + graphViewMode === "subgraph" ? graphSubgraphQuery.error - : graphViewMode === 'edges' - ? graphEdgesQuery.error - : graphEnrichedQuery.error; + : graphViewMode === "edges" + ? graphEdgesQuery.error + : graphEnrichedQuery.error; const graphElements = useMemo(() => { if (!currentGraph) { @@ -685,7 +688,7 @@ const ActorsPage: React.FC = () => { return buildActorGraphElements( currentGraph.nodes, currentGraph.edges, - currentGraph.rootNodeId || filters.actorId, + currentGraph.rootNodeId || filters.actorId ); }, [currentGraph, filters.actorId]); @@ -696,23 +699,23 @@ const ActorsPage: React.FC = () => { [ ...filters.edgeTypes, ...(graphEnrichedQuery.data?.subgraph.edges ?? []).map( - (edge) => edge.edgeType, + (edge) => edge.edgeType ), ...(graphSubgraphQuery.data?.edges ?? []).map( - (edge) => edge.edgeType, + (edge) => edge.edgeType ), ...(graphEdgesQuery.data ?? []).map((edge) => edge.edgeType), ] .map((value) => value.trim()) - .filter(Boolean), - ), + .filter(Boolean) + ) ).sort((left, right) => left.localeCompare(right)), [ filters.edgeTypes, graphEdgesQuery.data, graphEnrichedQuery.data?.subgraph.edges, graphSubgraphQuery.data?.edges, - ], + ] ); const graphSummary = useMemo(() => { @@ -726,7 +729,7 @@ const ActorsPage: React.FC = () => { depth: filters.graphDepth, take: filters.graphTake, edgeTypes: - filters.edgeTypes.length > 0 ? filters.edgeTypes.join(', ') : 'All', + filters.edgeTypes.length > 0 ? filters.edgeTypes.join(", ") : "All", rootNodeId: currentGraph.rootNodeId || filters.actorId, nodeCount: currentGraph.nodes.length, edgeCount: currentGraph.edges.length, @@ -743,7 +746,7 @@ const ActorsPage: React.FC = () => { const selectedNodeRecord = useMemo(() => { const node = currentGraph?.nodes.find( - (item) => item.nodeId === selectedNodeId, + (item) => item.nodeId === selectedNodeId ); if (!node) { return undefined; @@ -760,7 +763,7 @@ const ActorsPage: React.FC = () => { const selectedEdgeRecord = useMemo(() => { const edge = currentGraph?.edges.find( - (item) => item.edgeId === selectedEdgeId, + (item) => item.edgeId === selectedEdgeId ); if (!edge) { return undefined; @@ -773,32 +776,32 @@ const ActorsPage: React.FC = () => { }, [currentGraph, selectedEdgeId]); useEffect(() => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return; } const url = new URL(window.location.href); if (filters.actorId) { - url.searchParams.set('actorId', filters.actorId); + url.searchParams.set("actorId", filters.actorId); } else { - url.searchParams.delete('actorId'); + url.searchParams.delete("actorId"); } - url.searchParams.set('timelineTake', String(filters.timelineTake)); - url.searchParams.set('graphDepth', String(filters.graphDepth)); - url.searchParams.set('graphTake', String(filters.graphTake)); - url.searchParams.set('graphDirection', filters.graphDirection); - url.searchParams.set('graphView', graphViewMode); - url.searchParams.delete('edgeTypes'); + url.searchParams.set("timelineTake", String(filters.timelineTake)); + url.searchParams.set("graphDepth", String(filters.graphDepth)); + url.searchParams.set("graphTake", String(filters.graphTake)); + url.searchParams.set("graphDirection", filters.graphDirection); + url.searchParams.set("graphView", graphViewMode); + url.searchParams.delete("edgeTypes"); for (const edgeType of filters.edgeTypes) { - url.searchParams.append('edgeTypes', edgeType); + url.searchParams.append("edgeTypes", edgeType); } - window.history.replaceState(null, '', `${url.pathname}${url.search}`); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); }, [filters, graphViewMode]); useEffect(() => { timelineFormRef.current?.setFieldsValue(defaultTimelineFilters); setTimelineFilters(defaultTimelineFilters); - setSelectedTimelineKey(''); + setSelectedTimelineKey(""); }, [filters.actorId]); useEffect(() => { @@ -807,41 +810,63 @@ const ActorsPage: React.FC = () => { } if (!timelineRows.some((row) => row.key === selectedTimelineKey)) { - setSelectedTimelineKey(''); + setSelectedTimelineKey(""); } }, [selectedTimelineKey, timelineRows]); useEffect(() => { if (!currentGraph) { - setSelectedNodeId(''); - setSelectedEdgeId(''); + setSelectedNodeId(""); + setSelectedEdgeId(""); return; } if (!currentGraph.nodes.some((node) => node.nodeId === selectedNodeId)) { setSelectedNodeId( - currentGraph.rootNodeId || currentGraph.nodes[0]?.nodeId || '', + currentGraph.rootNodeId || currentGraph.nodes[0]?.nodeId || "" ); } if (!currentGraph.edges.some((edge) => edge.edgeId === selectedEdgeId)) { - setSelectedEdgeId(''); + setSelectedEdgeId(""); } }, [currentGraph, selectedEdgeId, selectedNodeId]); return ( - + + + + + + } + > formRef={formRef} layout="vertical" initialValues={initialState} onFinish={async (values) => { setFilters({ - actorId: (values.actorId ?? '').trim(), + actorId: (values.actorId ?? "").trim(), timelineTake: values.timelineTake, graphDepth: values.graphDepth, graphTake: values.graphTake, @@ -860,7 +885,7 @@ const ActorsPage: React.FC = () => { onClick={() => { formRef.current?.setFieldsValue(initialState); timelineFormRef.current?.setFieldsValue( - defaultTimelineFilters, + defaultTimelineFilters ); graphControlFormRef.current?.setFieldsValue({ graphViewMode: initialGraphViewMode, @@ -919,9 +944,9 @@ const ActorsPage: React.FC = () => { name="graphDirection" label="Graph direction" options={[ - { label: 'Both', value: 'Both' }, - { label: 'Outbound', value: 'Outbound' }, - { label: 'Inbound', value: 'Inbound' }, + { label: "Both", value: "Both" }, + { label: "Outbound", value: "Outbound" }, + { label: "Inbound", value: "Inbound" }, ]} /> @@ -934,9 +959,9 @@ const ActorsPage: React.FC = () => { value: edgeType, }))} fieldProps={{ - mode: 'multiple', + mode: "multiple", allowClear: true, - placeholder: 'Filter graph edge types', + placeholder: "Filter graph edge types", }} /> @@ -948,7 +973,7 @@ const ActorsPage: React.FC = () => { ) : null} @@ -996,7 +1021,7 @@ const ActorsPage: React.FC = () => { ) : ( @@ -1032,7 +1057,7 @@ const ActorsPage: React.FC = () => { stages: values.stages ?? [], eventTypes: values.eventTypes ?? [], stepTypes: values.stepTypes ?? [], - query: values.query ?? '', + query: values.query ?? "", errorsOnly: Boolean(values.errorsOnly), }); }} @@ -1051,9 +1076,9 @@ const ActorsPage: React.FC = () => { label="Stages" options={timelineStageOptions} fieldProps={{ - mode: 'multiple', + mode: "multiple", allowClear: true, - placeholder: 'All stages', + placeholder: "All stages", }} /> @@ -1063,9 +1088,9 @@ const ActorsPage: React.FC = () => { label="Event types" options={timelineEventTypeOptions} fieldProps={{ - mode: 'multiple', + mode: "multiple", allowClear: true, - placeholder: 'All event types', + placeholder: "All event types", }} /> @@ -1075,9 +1100,9 @@ const ActorsPage: React.FC = () => { label="Step types" options={timelineStepTypeOptions} fieldProps={{ - mode: 'multiple', + mode: "multiple", allowClear: true, - placeholder: 'All step types', + placeholder: "All step types", }} /> @@ -1097,7 +1122,7 @@ const ActorsPage: React.FC = () => { ) : null} @@ -1116,7 +1141,9 @@ const ActorsPage: React.FC = () => { onClick: () => setSelectedTimelineKey(record.key), })} rowClassName={(record) => - record.key === selectedTimelineKey ? 'ant-table-row-selected' : '' + record.key === selectedTimelineKey + ? "ant-table-row-selected" + : "" } locale={{ emptyText: ( @@ -1129,78 +1156,86 @@ const ActorsPage: React.FC = () => { /> setSelectedTimelineKey('')} - destroyOnClose + onClose={() => setSelectedTimelineKey("")} + destroyOnHidden > {selectedTimelineRecord ? ( - + column={1} dataSource={selectedTimelineRecord} columns={[ { - title: 'Timestamp', - dataIndex: 'timestamp', - render: (_, record) => formatDateTime(record.timestamp), + title: "Timestamp", + dataIndex: "timestamp", + render: (_, record) => + formatDateTime(record.timestamp), }, { - title: 'Stage', - dataIndex: 'stage', - render: (_, record) => record.stage || 'n/a', + title: "Stage", + dataIndex: "stage", + render: (_, record) => record.stage || "n/a", }, { - title: 'Event type', - dataIndex: 'eventType', - render: (_, record) => record.eventType || 'n/a', + title: "Event type", + dataIndex: "eventType", + render: (_, record) => record.eventType || "n/a", }, { - title: 'Message', - dataIndex: 'message', - render: (_, record) => record.message || 'n/a', + title: "Message", + dataIndex: "message", + render: (_, record) => record.message || "n/a", }, { - title: 'Step', - dataIndex: 'stepId', - render: (_, record) => record.stepId || 'n/a', + title: "Step", + dataIndex: "stepId", + render: (_, record) => record.stepId || "n/a", }, { - title: 'Step type', - dataIndex: 'stepType', - render: (_, record) => record.stepType || 'n/a', + title: "Step type", + dataIndex: "stepType", + render: (_, record) => record.stepType || "n/a", }, { - title: 'Actor', - dataIndex: 'agentId', - render: (_, record) => record.agentId || 'n/a', + title: "Actor", + dataIndex: "agentId", + render: (_, record) => record.agentId || "n/a", }, ]} />
- Structured data + + Structured data +
{selectedTimelineRecord.dataCount > 0 ? ( - {Object.entries(selectedTimelineRecord.data).map( - ([key, value]) => ( - - - {key} - - : {value || 'n/a'} + {Object.entries( + selectedTimelineRecord.data + ).map(([key, value]) => ( + + + {key} - ), - )} + : {value || "n/a"} + + ))} ) : ( - No structured data was attached to this timeline entry. + No structured data was attached to this timeline + entry. )}
@@ -1212,11 +1247,15 @@ const ActorsPage: React.FC = () => {
-                              {JSON.stringify(selectedTimelineRecord.data, null, 2)}
+                              {JSON.stringify(
+                                selectedTimelineRecord.data,
+                                null,
+                                2
+                              )}
                             
) : null} @@ -1281,7 +1320,7 @@ const ActorsPage: React.FC = () => { ) : selectedNodeRecord || selectedEdgeRecord ? ( @@ -1329,7 +1368,7 @@ const ActorsPage: React.FC = () => { { ) : currentGraph && currentGraph.nodes.length > 0 ? ( diff --git a/apps/aevatar-console-web/src/pages/governance/activation.tsx b/apps/aevatar-console-web/src/pages/governance/activation.tsx new file mode 100644 index 00000000..42227e2f --- /dev/null +++ b/apps/aevatar-console-web/src/pages/governance/activation.tsx @@ -0,0 +1,181 @@ +import { PageContainer, ProCard, ProTable } from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import { Alert, Col, Row, Space } from "antd"; +import React, { useMemo, useState } from "react"; +import { governanceApi } from "@/shared/api/governanceApi"; +import { servicesApi } from "@/shared/api/servicesApi"; +import type { + ServiceBindingSnapshot, + ServiceEndpointExposureSnapshot, + ServicePolicySnapshot, +} from "@/shared/models/governance"; +import { + compactTableCardProps, + moduleCardProps, +} from "@/shared/ui/proComponents"; +import { + bindingColumns, + endpointColumns, + policyColumns, +} from "./components/columns"; +import GovernanceQueryCard from "./components/GovernanceQueryCard"; +import { + buildGovernanceHref, + normalizeGovernanceDraft, + normalizeGovernanceQuery, + readGovernanceDraft, + type GovernanceDraft, +} from "./components/governanceQuery"; + +const initialDraft = readGovernanceDraft(); + +const GovernanceActivationPage: React.FC = () => { + const [draft, setDraft] = useState(initialDraft); + const [activeDraft, setActiveDraft] = useState(initialDraft); + + const query = useMemo( + () => normalizeGovernanceQuery(activeDraft), + [activeDraft] + ); + + const servicesQuery = useQuery({ + queryKey: ["governance", "activation", "services", query], + queryFn: () => servicesApi.listServices({ ...query, take: 200 }), + }); + const activationQuery = useQuery({ + queryKey: [ + "governance", + "activation", + query, + activeDraft.serviceId, + activeDraft.revisionId, + ], + enabled: + activeDraft.serviceId.trim().length > 0 && + activeDraft.revisionId.trim().length > 0, + queryFn: () => + governanceApi.getActivationCapability(activeDraft.serviceId, { + ...query, + revisionId: activeDraft.revisionId, + }), + }); + + const serviceOptions = useMemo( + () => + (servicesQuery.data ?? []).map((item) => ({ + label: item.displayName + ? `${item.displayName} (${item.serviceId})` + : item.serviceId, + value: item.serviceId, + })), + [servicesQuery.data] + ); + + const activationView = activationQuery.data; + + return ( + + history.push(buildGovernanceHref("/governance", activeDraft)) + } + > + + + { + const nextActiveDraft = normalizeGovernanceDraft(draft); + setDraft(nextActiveDraft); + setActiveDraft(nextActiveDraft); + history.replace( + buildGovernanceHref("/governance/activation", nextActiveDraft) + ); + }} + onReset={() => { + const nextDraft = readGovernanceDraft(""); + setDraft(nextDraft); + setActiveDraft(nextDraft); + history.replace("/governance/activation"); + }} + /> + + + {!activeDraft.serviceId.trim() || !activeDraft.revisionId.trim() ? ( + + ) : activationView ? ( + + 0 + ? "warning" + : "success" + } + title={`Revision ${activationView.revisionId}`} + description={ + activationView.missingPolicyIds.length > 0 + ? `Missing policies: ${activationView.missingPolicyIds.join( + ", " + )}` + : "No missing policy references." + } + /> + + + columns={bindingColumns} + dataSource={activationView.bindings} + rowKey="bindingId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + + + + columns={policyColumns} + dataSource={activationView.policies} + rowKey="policyId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + + + + columns={endpointColumns} + dataSource={activationView.endpoints} + rowKey="endpointId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + + + ) : ( + + )} + + + + ); +}; + +export default GovernanceActivationPage; diff --git a/apps/aevatar-console-web/src/pages/governance/bindings.tsx b/apps/aevatar-console-web/src/pages/governance/bindings.tsx new file mode 100644 index 00000000..79e5c3e4 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/governance/bindings.tsx @@ -0,0 +1,107 @@ +import { PageContainer, ProTable } from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import { Alert, Col, Row } from "antd"; +import React, { useMemo, useState } from "react"; +import { governanceApi } from "@/shared/api/governanceApi"; +import { servicesApi } from "@/shared/api/servicesApi"; +import type { ServiceBindingSnapshot } from "@/shared/models/governance"; +import { compactTableCardProps } from "@/shared/ui/proComponents"; +import { bindingColumns } from "./components/columns"; +import GovernanceQueryCard from "./components/GovernanceQueryCard"; +import { + buildGovernanceHref, + normalizeGovernanceDraft, + normalizeGovernanceQuery, + readGovernanceDraft, + type GovernanceDraft, +} from "./components/governanceQuery"; + +const initialDraft = readGovernanceDraft(); + +const GovernanceBindingsPage: React.FC = () => { + const [draft, setDraft] = useState(initialDraft); + const [activeDraft, setActiveDraft] = useState(initialDraft); + + const query = useMemo( + () => normalizeGovernanceQuery(activeDraft), + [activeDraft] + ); + + const servicesQuery = useQuery({ + queryKey: ["governance", "bindings", "services", query], + queryFn: () => servicesApi.listServices({ ...query, take: 200 }), + }); + const bindingsQuery = useQuery({ + queryKey: ["governance", "bindings", query, activeDraft.serviceId], + enabled: activeDraft.serviceId.trim().length > 0, + queryFn: () => governanceApi.getBindings(activeDraft.serviceId, query), + }); + + const serviceOptions = useMemo( + () => + (servicesQuery.data ?? []).map((item) => ({ + label: item.displayName + ? `${item.displayName} (${item.serviceId})` + : item.serviceId, + value: item.serviceId, + })), + [servicesQuery.data] + ); + + return ( + + history.push(buildGovernanceHref("/governance", activeDraft)) + } + > + + + { + const nextActiveDraft = normalizeGovernanceDraft(draft); + setDraft(nextActiveDraft); + setActiveDraft(nextActiveDraft); + history.replace( + buildGovernanceHref("/governance/bindings", nextActiveDraft) + ); + }} + onReset={() => { + const nextDraft = readGovernanceDraft(""); + setDraft(nextDraft); + setActiveDraft(nextDraft); + history.replace("/governance/bindings"); + }} + /> + + + {activeDraft.serviceId.trim() ? ( + + columns={bindingColumns} + dataSource={bindingsQuery.data?.bindings ?? []} + loading={bindingsQuery.isLoading} + rowKey="bindingId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ) : ( + + )} + + + + ); +}; + +export default GovernanceBindingsPage; diff --git a/apps/aevatar-console-web/src/pages/governance/components/GovernanceQueryCard.tsx b/apps/aevatar-console-web/src/pages/governance/components/GovernanceQueryCard.tsx new file mode 100644 index 00000000..dfebdf87 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/governance/components/GovernanceQueryCard.tsx @@ -0,0 +1,104 @@ +import { ProCard } from '@ant-design/pro-components'; +import { Button, Input, Select, Space } from 'antd'; +import React from 'react'; +import { moduleCardProps } from '@/shared/ui/proComponents'; +import type { GovernanceDraft } from './governanceQuery'; + +type GovernanceQueryCardProps = { + draft: GovernanceDraft; + serviceOptions: Array<{ label: string; value: string }>; + includeRevision?: boolean; + loadLabel?: string; + onChange: (draft: GovernanceDraft) => void; + onLoad: () => void; + onReset?: () => void; +}; + +const GovernanceQueryCard: React.FC = ({ + draft, + serviceOptions, + includeRevision = false, + loadLabel = 'Load governance', + onChange, + onLoad, + onReset, +}) => { + return ( + + + + onChange({ + ...draft, + tenantId: event.target.value, + }) + } + /> + + onChange({ + ...draft, + appId: event.target.value, + }) + } + /> + + onChange({ + ...draft, + namespace: event.target.value, + }) + } + /> + + onChange({ + ...draft, + revisionId: event.target.value, + }) + } + /> + ) : null} + + {onReset ? : null} + + + ); +}; + +export default GovernanceQueryCard; diff --git a/apps/aevatar-console-web/src/pages/governance/components/columns.tsx b/apps/aevatar-console-web/src/pages/governance/components/columns.tsx new file mode 100644 index 00000000..98123a29 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/governance/components/columns.tsx @@ -0,0 +1,108 @@ +import type { ProColumns } from '@ant-design/pro-components'; +import { Typography } from 'antd'; +import React from 'react'; +import type { + ServiceBindingSnapshot, + ServiceEndpointExposureSnapshot, + ServicePolicySnapshot, +} from '@/shared/models/governance'; + +export const bindingColumns: ProColumns[] = [ + { + title: 'Binding', + dataIndex: 'bindingId', + }, + { + title: 'Display name', + dataIndex: 'displayName', + }, + { + title: 'Kind', + dataIndex: 'bindingKind', + }, + { + title: 'Policies', + render: (_, record) => record.policyIds.join(', ') || 'n/a', + }, + { + title: 'Target', + render: (_, record) => { + if (record.serviceRef) { + return `${record.serviceRef.identity.serviceId}:${ + record.serviceRef.endpointId || '*' + }`; + } + if (record.connectorRef) { + return `${record.connectorRef.connectorType}:${record.connectorRef.connectorId}`; + } + if (record.secretRef) { + return record.secretRef.secretName; + } + return 'n/a'; + }, + }, + { + title: 'Retired', + render: (_, record) => (record.retired ? 'yes' : 'no'), + }, +]; + +export const policyColumns: ProColumns[] = [ + { + title: 'Policy', + dataIndex: 'policyId', + }, + { + title: 'Display name', + dataIndex: 'displayName', + }, + { + title: 'Activation bindings', + render: (_, record) => + record.activationRequiredBindingIds.join(', ') || 'n/a', + }, + { + title: 'Allowed callers', + render: (_, record) => + record.invokeAllowedCallerServiceKeys.join(', ') || 'n/a', + }, + { + title: 'Active deployment required', + render: (_, record) => + record.invokeRequiresActiveDeployment ? 'yes' : 'no', + }, + { + title: 'Retired', + render: (_, record) => (record.retired ? 'yes' : 'no'), + }, +]; + +export const endpointColumns: ProColumns[] = [ + { + title: 'Endpoint', + dataIndex: 'endpointId', + }, + { + title: 'Display name', + dataIndex: 'displayName', + }, + { + title: 'Kind', + dataIndex: 'kind', + }, + { + title: 'Exposure', + dataIndex: 'exposureKind', + }, + { + title: 'Request type', + dataIndex: 'requestTypeUrl', + render: (_, record) => ( + {record.requestTypeUrl} + ), + }, + { + title: 'Policies', + render: (_, record) => record.policyIds.join(', ') || 'n/a', + }, +]; diff --git a/apps/aevatar-console-web/src/pages/governance/components/governanceQuery.test.ts b/apps/aevatar-console-web/src/pages/governance/components/governanceQuery.test.ts new file mode 100644 index 00000000..f22af9a9 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/governance/components/governanceQuery.test.ts @@ -0,0 +1,59 @@ +import { + buildGovernanceHref, + normalizeGovernanceDraft, + normalizeGovernanceQuery, + readGovernanceDraft, +} from './governanceQuery'; + +describe('governanceQuery', () => { + it('reads governance filters from the query string', () => { + expect( + readGovernanceDraft( + '?tenantId=t1&appId=a1&namespace=n1&serviceId=svc-1&revisionId=rev-2', + ), + ).toEqual({ + tenantId: 't1', + appId: 'a1', + namespace: 'n1', + serviceId: 'svc-1', + revisionId: 'rev-2', + }); + }); + + it('normalizes service identity query and full governance draft separately', () => { + const draft = { + tenantId: ' t1 ', + appId: ' a1 ', + namespace: ' n1 ', + serviceId: ' svc-1 ', + revisionId: ' rev-2 ', + }; + + expect(normalizeGovernanceQuery(draft)).toEqual({ + tenantId: 't1', + appId: 'a1', + namespace: 'n1', + }); + expect(normalizeGovernanceDraft(draft)).toEqual({ + tenantId: 't1', + appId: 'a1', + namespace: 'n1', + serviceId: 'svc-1', + revisionId: 'rev-2', + }); + }); + + it('builds governance routes that preserve service and revision context', () => { + expect( + buildGovernanceHref('/governance/activation', { + tenantId: 't1', + appId: 'a1', + namespace: 'n1', + serviceId: 'svc-1', + revisionId: 'rev-2', + }), + ).toBe( + '/governance/activation?tenantId=t1&appId=a1&namespace=n1&serviceId=svc-1&revisionId=rev-2', + ); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/governance/components/governanceQuery.ts b/apps/aevatar-console-web/src/pages/governance/components/governanceQuery.ts new file mode 100644 index 00000000..80f560c0 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/governance/components/governanceQuery.ts @@ -0,0 +1,74 @@ +import type { ServiceIdentityQuery } from '@/shared/models/services'; + +export type GovernanceDraft = { + tenantId: string; + appId: string; + namespace: string; + serviceId: string; + revisionId: string; +}; + +function readString(value: string | null): string { + return value?.trim() ?? ''; +} + +export function normalizeGovernanceQuery( + draft: GovernanceDraft, +): ServiceIdentityQuery { + return { + tenantId: draft.tenantId.trim(), + appId: draft.appId.trim(), + namespace: draft.namespace.trim(), + }; +} + +export function normalizeGovernanceDraft( + draft: GovernanceDraft, +): GovernanceDraft { + return { + tenantId: draft.tenantId.trim(), + appId: draft.appId.trim(), + namespace: draft.namespace.trim(), + serviceId: draft.serviceId.trim(), + revisionId: draft.revisionId.trim(), + }; +} + +export function readGovernanceDraft( + search = typeof window === 'undefined' ? '' : window.location.search, +): GovernanceDraft { + const params = new URLSearchParams(search); + return { + tenantId: readString(params.get('tenantId')), + appId: readString(params.get('appId')), + namespace: readString(params.get('namespace')), + serviceId: readString(params.get('serviceId')), + revisionId: readString(params.get('revisionId')), + }; +} + +export function buildGovernanceHref( + path: string, + draft: GovernanceDraft, +): string { + const params = new URLSearchParams(); + + if (draft.tenantId.trim()) { + params.set('tenantId', draft.tenantId.trim()); + } + if (draft.appId.trim()) { + params.set('appId', draft.appId.trim()); + } + if (draft.namespace.trim()) { + params.set('namespace', draft.namespace.trim()); + } + if (draft.serviceId.trim()) { + params.set('serviceId', draft.serviceId.trim()); + } + if (draft.revisionId.trim()) { + params.set('revisionId', draft.revisionId.trim()); + } + + const suffix = params.toString(); + return suffix ? `${path}?${suffix}` : path; +} diff --git a/apps/aevatar-console-web/src/pages/governance/endpoints.tsx b/apps/aevatar-console-web/src/pages/governance/endpoints.tsx new file mode 100644 index 00000000..9ea42cb9 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/governance/endpoints.tsx @@ -0,0 +1,108 @@ +import { PageContainer, ProTable } from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import { Alert, Col, Row } from "antd"; +import React, { useMemo, useState } from "react"; +import { governanceApi } from "@/shared/api/governanceApi"; +import { servicesApi } from "@/shared/api/servicesApi"; +import type { ServiceEndpointExposureSnapshot } from "@/shared/models/governance"; +import { compactTableCardProps } from "@/shared/ui/proComponents"; +import { endpointColumns } from "./components/columns"; +import GovernanceQueryCard from "./components/GovernanceQueryCard"; +import { + buildGovernanceHref, + normalizeGovernanceDraft, + normalizeGovernanceQuery, + readGovernanceDraft, + type GovernanceDraft, +} from "./components/governanceQuery"; + +const initialDraft = readGovernanceDraft(); + +const GovernanceEndpointsPage: React.FC = () => { + const [draft, setDraft] = useState(initialDraft); + const [activeDraft, setActiveDraft] = useState(initialDraft); + + const query = useMemo( + () => normalizeGovernanceQuery(activeDraft), + [activeDraft] + ); + + const servicesQuery = useQuery({ + queryKey: ["governance", "endpoints", "services", query], + queryFn: () => servicesApi.listServices({ ...query, take: 200 }), + }); + const endpointsQuery = useQuery({ + queryKey: ["governance", "endpoints", query, activeDraft.serviceId], + enabled: activeDraft.serviceId.trim().length > 0, + queryFn: () => + governanceApi.getEndpointCatalog(activeDraft.serviceId, query), + }); + + const serviceOptions = useMemo( + () => + (servicesQuery.data ?? []).map((item) => ({ + label: item.displayName + ? `${item.displayName} (${item.serviceId})` + : item.serviceId, + value: item.serviceId, + })), + [servicesQuery.data] + ); + + return ( + + history.push(buildGovernanceHref("/governance", activeDraft)) + } + > + + + { + const nextActiveDraft = normalizeGovernanceDraft(draft); + setDraft(nextActiveDraft); + setActiveDraft(nextActiveDraft); + history.replace( + buildGovernanceHref("/governance/endpoints", nextActiveDraft) + ); + }} + onReset={() => { + const nextDraft = readGovernanceDraft(""); + setDraft(nextDraft); + setActiveDraft(nextDraft); + history.replace("/governance/endpoints"); + }} + /> + + + {activeDraft.serviceId.trim() ? ( + + columns={endpointColumns} + dataSource={endpointsQuery.data?.endpoints ?? []} + loading={endpointsQuery.isLoading} + rowKey="endpointId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ) : ( + + )} + + + + ); +}; + +export default GovernanceEndpointsPage; diff --git a/apps/aevatar-console-web/src/pages/governance/policies.tsx b/apps/aevatar-console-web/src/pages/governance/policies.tsx new file mode 100644 index 00000000..0d68d29e --- /dev/null +++ b/apps/aevatar-console-web/src/pages/governance/policies.tsx @@ -0,0 +1,107 @@ +import { PageContainer, ProTable } from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import { Alert, Col, Row } from "antd"; +import React, { useMemo, useState } from "react"; +import { governanceApi } from "@/shared/api/governanceApi"; +import { servicesApi } from "@/shared/api/servicesApi"; +import type { ServicePolicySnapshot } from "@/shared/models/governance"; +import { compactTableCardProps } from "@/shared/ui/proComponents"; +import { policyColumns } from "./components/columns"; +import GovernanceQueryCard from "./components/GovernanceQueryCard"; +import { + buildGovernanceHref, + normalizeGovernanceDraft, + normalizeGovernanceQuery, + readGovernanceDraft, + type GovernanceDraft, +} from "./components/governanceQuery"; + +const initialDraft = readGovernanceDraft(); + +const GovernancePoliciesPage: React.FC = () => { + const [draft, setDraft] = useState(initialDraft); + const [activeDraft, setActiveDraft] = useState(initialDraft); + + const query = useMemo( + () => normalizeGovernanceQuery(activeDraft), + [activeDraft] + ); + + const servicesQuery = useQuery({ + queryKey: ["governance", "policies", "services", query], + queryFn: () => servicesApi.listServices({ ...query, take: 200 }), + }); + const policiesQuery = useQuery({ + queryKey: ["governance", "policies", query, activeDraft.serviceId], + enabled: activeDraft.serviceId.trim().length > 0, + queryFn: () => governanceApi.getPolicies(activeDraft.serviceId, query), + }); + + const serviceOptions = useMemo( + () => + (servicesQuery.data ?? []).map((item) => ({ + label: item.displayName + ? `${item.displayName} (${item.serviceId})` + : item.serviceId, + value: item.serviceId, + })), + [servicesQuery.data] + ); + + return ( + + history.push(buildGovernanceHref("/governance", activeDraft)) + } + > + + + { + const nextActiveDraft = normalizeGovernanceDraft(draft); + setDraft(nextActiveDraft); + setActiveDraft(nextActiveDraft); + history.replace( + buildGovernanceHref("/governance/policies", nextActiveDraft) + ); + }} + onReset={() => { + const nextDraft = readGovernanceDraft(""); + setDraft(nextDraft); + setActiveDraft(nextDraft); + history.replace("/governance/policies"); + }} + /> + + + {activeDraft.serviceId.trim() ? ( + + columns={policyColumns} + dataSource={policiesQuery.data?.policies ?? []} + loading={policiesQuery.isLoading} + rowKey="policyId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ) : ( + + )} + + + + ); +}; + +export default GovernancePoliciesPage; diff --git a/apps/aevatar-console-web/src/pages/observability/index.test.tsx b/apps/aevatar-console-web/src/pages/observability/index.test.tsx new file mode 100644 index 00000000..0306e006 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/observability/index.test.tsx @@ -0,0 +1,35 @@ +import { screen } from "@testing-library/react"; +import React from "react"; +import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; +import ObservabilityPage from "./index"; + +describe("ObservabilityPage", () => { + beforeEach(() => { + window.history.replaceState( + null, + "", + "/observability?workflow=direct&actorId=Workflow:19fe1b04&commandId=cmd-123" + ); + }); + + it("renders platform-oriented observability jumps", async () => { + const { container } = renderWithQueryClient( + React.createElement(ObservabilityPage) + ); + + expect(container.textContent).toContain("Observability"); + expect(container.textContent).toContain( + "Use configured external tools as the jump hub for runtime, scopes, services, governance, and local settings without adding new backend APIs." + ); + expect(container.textContent).toContain("Console surfaces"); + expect(container.textContent).toContain("Open Runtime Explorer"); + expect(container.textContent).toContain("Open Runtime Settings"); + expect(container.textContent).toContain("Open Console Settings"); + expect(container.textContent).toContain("Open Scopes"); + expect(container.textContent).toContain("Open Services"); + expect(container.textContent).toContain("Open Governance"); + expect(screen.getByText("direct")).toBeTruthy(); + expect(screen.getByText("Workflow:19fe1b04")).toBeTruthy(); + expect(screen.getByText("cmd-123")).toBeTruthy(); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/observability/index.tsx b/apps/aevatar-console-web/src/pages/observability/index.tsx index 75a31f38..b8d2fe04 100644 --- a/apps/aevatar-console-web/src/pages/observability/index.tsx +++ b/apps/aevatar-console-web/src/pages/observability/index.tsx @@ -5,25 +5,25 @@ import { ProForm, ProFormText, ProList, -} from '@ant-design/pro-components'; +} from "@ant-design/pro-components"; import type { ProDescriptionsItemProps, ProFormInstance, -} from '@ant-design/pro-components'; -import { history } from '@umijs/max'; -import { Alert, Button, Col, Empty, Row, Space, Tag, Typography } from 'antd'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +} from "@ant-design/pro-components"; +import { history } from "@umijs/max"; +import { Alert, Button, Col, Empty, Row, Space, Tag, Typography } from "antd"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { buildObservabilityTargets, type ObservabilityContext, type ObservabilityTarget, -} from '@/shared/observability/observabilityLinks'; -import { loadConsolePreferences } from '@/shared/preferences/consolePreferences'; +} from "@/shared/observability/observabilityLinks"; +import { loadConsolePreferences } from "@/shared/preferences/consolePreferences"; import { fillCardStyle, moduleCardProps, stretchColumnStyle, -} from '@/shared/ui/proComponents'; +} from "@/shared/ui/proComponents"; type ObservabilityContextForm = ObservabilityContext; @@ -44,56 +44,56 @@ type InternalJumpItem = { }; const targetStatusValueEnum = { - configured: { text: 'Configured', status: 'Success' }, - missing: { text: 'Missing', status: 'Default' }, + configured: { text: "Configured", status: "Success" }, + missing: { text: "Missing", status: "Default" }, } as const; const summaryColumns: ProDescriptionsItemProps[] = [ { - title: 'Configured targets', - dataIndex: 'configuredCount', - valueType: 'digit', + title: "Configured targets", + dataIndex: "configuredCount", + valueType: "digit", }, { - title: 'Missing targets', - dataIndex: 'missingCount', - valueType: 'digit', + title: "Missing targets", + dataIndex: "missingCount", + valueType: "digit", }, { - title: 'Workflow context', - dataIndex: 'workflow', - render: (_, record) => record.workflow || 'n/a', + title: "Workflow context", + dataIndex: "workflow", + render: (_, record) => record.workflow || "n/a", }, { - title: 'Actor context', - dataIndex: 'actorId', - render: (_, record) => record.actorId || 'n/a', + title: "Actor context", + dataIndex: "actorId", + render: (_, record) => record.actorId || "n/a", }, { - title: 'Command context', - dataIndex: 'commandId', - render: (_, record) => record.commandId || 'n/a', + title: "Command context", + dataIndex: "commandId", + render: (_, record) => record.commandId || "n/a", }, ]; function readContextFromUrl(): ObservabilityContext { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return { - workflow: '', - actorId: '', - commandId: '', - runId: '', - stepId: '', + workflow: "", + actorId: "", + commandId: "", + runId: "", + stepId: "", }; } const params = new URLSearchParams(window.location.search); return { - workflow: params.get('workflow') ?? '', - actorId: params.get('actorId') ?? '', - commandId: params.get('commandId') ?? '', - runId: params.get('runId') ?? '', - stepId: params.get('stepId') ?? '', + workflow: params.get("workflow") ?? "", + actorId: params.get("actorId") ?? "", + commandId: params.get("commandId") ?? "", + runId: params.get("runId") ?? "", + stepId: params.get("stepId") ?? "", }; } @@ -101,79 +101,117 @@ const ObservabilityPage: React.FC = () => { const preferences = useMemo(() => loadConsolePreferences(), []); const initialContext = useMemo(() => readContextFromUrl(), []); const formRef = useRef | undefined>( - undefined, + undefined ); const [context, setContext] = useState(initialContext); const targets = useMemo( () => buildObservabilityTargets(preferences, context), - [context, preferences], + [context, preferences] ); const summaryRecord = useMemo( () => ({ - configuredCount: targets.filter((target) => target.status === 'configured').length, - missingCount: targets.filter((target) => target.status === 'missing').length, + configuredCount: targets.filter( + (target) => target.status === "configured" + ).length, + missingCount: targets.filter((target) => target.status === "missing") + .length, workflow: context.workflow, actorId: context.actorId, commandId: context.commandId, }), - [context.actorId, context.commandId, context.workflow, targets], + [context.actorId, context.commandId, context.workflow, targets] ); const internalJumps = useMemo( () => [ { - id: 'jump-runs', - title: 'Open Runs', + id: "jump-runs", + title: "Open Runs", description: context.workflow ? `Open Runs with workflow=${context.workflow}.` - : 'Open Runs and keep the current workflow selection manual.', + : "Open Runs and keep the current workflow selection manual.", href: context.workflow ? `/runs?workflow=${encodeURIComponent(context.workflow)}` - : '/runs', + : "/runs", enabled: true, }, { - id: 'jump-actors', - title: 'Open Actor Explorer', + id: "jump-actors", + title: "Open Runtime Explorer", description: context.actorId - ? `Open Actors with actorId=${context.actorId}.` - : 'Provide actorId first to jump directly to Actor Explorer.', + ? `Open Runtime Explorer with actorId=${context.actorId}.` + : "Provide actorId first to jump directly to Runtime Explorer.", href: context.actorId ? `/actors?actorId=${encodeURIComponent(context.actorId)}` - : '/actors', + : "/actors", enabled: Boolean(context.actorId), }, { - id: 'jump-workflows', - title: 'Open Workflow Detail', + id: "jump-workflows", + title: "Open Workflow Detail", description: context.workflow ? `Open Workflows with workflow=${context.workflow}.` - : 'Provide workflow first to jump directly to Workflow detail.', + : "Provide workflow first to jump directly to Workflow detail.", href: context.workflow ? `/workflows?workflow=${encodeURIComponent(context.workflow)}` - : '/workflows', + : "/workflows", enabled: Boolean(context.workflow), }, { - id: 'jump-settings', - title: 'Open Settings', - description: 'Manage observability endpoint URLs and console preferences.', - href: '/settings', + id: "jump-settings", + title: "Open Console Settings", + description: + "Manage observability endpoint URLs and console preferences.", + href: "/settings/console", + enabled: true, + }, + { + id: "jump-settings-runtime", + title: "Open Runtime Settings", + description: + "Manage local workflows, providers, connectors, MCP servers, secrets, and raw configuration files.", + href: "/settings/runtime", + enabled: true, + }, + { + id: "jump-scopes", + title: "Open Scopes", + description: + "Inspect published workflow and script assets owned by GAgentService scopes.", + href: "/scopes", + enabled: true, + }, + { + id: "jump-services", + title: "Open Services", + description: + "Inspect services, revisions, serving targets, rollouts, and traffic exposure.", + href: "/services", + enabled: true, + }, + { + id: "jump-governance", + title: "Open Governance", + description: + "Inspect bindings, policies, endpoint exposure, and activation capability views.", + href: "/governance", enabled: true, }, ], - [context.actorId, context.workflow], + [context.actorId, context.workflow] ); useEffect(() => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return; } const url = new URL(window.location.href); - const entries = Object.entries(context) as Array<[keyof ObservabilityContext, string]>; + const entries = Object.entries(context) as Array< + [keyof ObservabilityContext, string] + >; for (const [key, value] of entries) { if (value) { url.searchParams.set(key, value); @@ -181,43 +219,50 @@ const ObservabilityPage: React.FC = () => { url.searchParams.delete(key); } } - window.history.replaceState(null, '', `${url.pathname}${url.search}`); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); }, [context]); return ( - + formRef={formRef} layout="vertical" initialValues={initialContext} onFinish={async (values) => { setContext({ - workflow: values.workflow?.trim() ?? '', - actorId: values.actorId?.trim() ?? '', - commandId: values.commandId?.trim() ?? '', - runId: values.runId?.trim() ?? '', - stepId: values.stepId?.trim() ?? '', + workflow: values.workflow?.trim() ?? "", + actorId: values.actorId?.trim() ?? "", + commandId: values.commandId?.trim() ?? "", + runId: values.runId?.trim() ?? "", + stepId: values.stepId?.trim() ?? "", }); return true; }} submitter={{ render: (props) => ( - - ) : ( - Not configured - )} - + + Observability + {grafanaBaseUrl ? ( + + ) : ( + Not configured + )} + - - - - - - - - - - - {grafanaBaseUrl ? ( - - ) : null} - + + +
+ Platform entry points + + Open runtime, scope, service, governance, and capability + surfaces. + +
+ + {platformQuickActions.map((item) => ( + + ))} + +
+
-
- Human-in-the-loop workflows -
- - {humanFocusedWorkflows.length > 0 ? ( - humanFocusedWorkflows.map((item) => ( +
+ Local console tools + + Jump into browser-level preferences, local runtime + configuration, and external observability tools. + +
+ + {localQuickActions.map((item) => ( - )) - ) : ( - - No human-interaction workflows were discovered in the catalog. - - )} - + ))} + +
-
- + +
+ + Human-in-the-loop workflows + +
+ + {humanFocusedWorkflows.length > 0 ? ( + humanFocusedWorkflows.map((item) => ( + + )) + ) : ( + + No human-interaction workflows were discovered in the + catalog. + + )} + +
+
+ - - - - column={1} - dataSource={profileData} - columns={profileColumns} - /> + + + + column={1} + dataSource={profileData} + columns={profileColumns} + /> -
- Live actor shortcuts -
- - {liveActors.length > 0 ? ( - liveActors.map((agent) => ( - - )) - ) : ( - - No live actors were returned by the backend. - - )} - +
+ Live actor shortcuts +
+ + {liveActors.length > 0 ? ( + liveActors.map((agent) => ( + + )) + ) : ( + + No live actors were returned by the backend. + + )} + +
-
- + - - - {studioSurfaceItems.map((item) => ( - - -
- - {item.title} - {item.summary} - - {item.description} - -
-
- - ))} -
+ +
+ + {capabilitySurfaceItems.map((item) => ( + + +
+ + {item.title} + {item.summary} + + + {item.description} + + +
+
+ + ))} +
+
- {capabilitiesQuery.isError ? ( - - ) : ( -
- - - {capabilitiesQuery.data?.schemaVersion ?? 'capabilities.v1'} - - - Updated{' '} - {capabilitiesQuery.data?.generatedAtUtc - ? formatDateTime(capabilitiesQuery.data.generatedAtUtc) - : 'n/a'} - - {capabilitiesQuery.data?.primitives.length ?? 0} primitives - {capabilitiesQuery.data?.connectors.length ?? 0} connectors - {capabilitiesQuery.data?.workflows.length ?? 0} workflows - - - Connectors: {capabilityConnectorSummary} - + {capabilitiesQuery.isError ? ( + + ) : ( +
+ + + {capabilitiesQuery.data?.schemaVersion ?? "capabilities.v1"} + + + Updated{" "} + {capabilitiesQuery.data?.generatedAtUtc + ? formatDateTime(capabilitiesQuery.data.generatedAtUtc) + : "n/a"} + + + {capabilitiesQuery.data?.primitives.length ?? 0} primitives + + + {capabilitiesQuery.data?.connectors.length ?? 0} connectors + + + {capabilitiesQuery.data?.workflows.length ?? 0} workflows + + + + + + + + + + + + + + + + + + + -
- Primitive preview -
- {capabilityPrimitivePreview.length > 0 ? ( - <> - - {capabilityPrimitivePreview.map((primitive) => ( - - {primitive.name} - - ))} - - - {capabilityPrimitiveCategorySummary.join(' · ')} - - - ) : ( - - No primitives were returned by the runtime capability snapshot. - - )} +
+ Primitive categories + + {capabilityPrimitiveCategorySummary.length > 0 + ? capabilityPrimitiveCategorySummary.join(" · ") + : "No primitive categories were returned by the runtime capability digest."} +
-
-
- Workflow coverage -
- {capabilitiesQuery.data?.workflows.length ? ( - <> - - - - - - - - - - - - - - - - - - - {capabilityWorkflowSummary.sourceSummary.length > 0 - ? `Source mix: ${capabilityWorkflowSummary.sourceSummary.join(' · ')}` - : 'Source mix unavailable'} - - - ) : ( - - No capability workflows were exposed by the backend. - - )} +
+ + Connector availability + + + {capabilityConnectorSummary} +
-
- - Overview keeps this as a summary. Use Workflows, Studio, or Primitives for full - per-item details. - +
+ Workflow source mix + + {capabilityWorkflowSourceSummary.length > 0 + ? capabilityWorkflowSourceSummary.join(" · ") + : "No capability workflows were exposed by the backend."} + +
- - - - -
- )} + + Overview keeps this as a digest. Use Primitives, Workflows, + and Runtime Settings for full details. + + + + + + + +
+ )} - + rowKey="id" search={false} @@ -556,22 +632,30 @@ const OverviewPage: React.FC = () => { }} metas={{ title: { - dataIndex: 'label', + dataIndex: "label", render: (_, record) => ( {record.label} - + {record.status} ), }, description: { - dataIndex: 'description', + dataIndex: "description", }, subTitle: { render: (_, record) => - record.homeUrl ? {record.homeUrl} : No URL configured, + record.homeUrl ? ( + {record.homeUrl} + ) : ( + No URL configured + ), }, actions: { render: (_, record) => [ @@ -581,8 +665,8 @@ const OverviewPage: React.FC = () => { onClick={() => history.push( `/observability?workflow=${encodeURIComponent( - preferences.preferredWorkflow, - )}`, + preferences.preferredWorkflow + )}` ) } > @@ -591,7 +675,7 @@ const OverviewPage: React.FC = () => { - - -
- - - ); -}; - -export default PlaygroundPage; diff --git a/apps/aevatar-console-web/src/pages/primitives/index.test.tsx b/apps/aevatar-console-web/src/pages/primitives/index.test.tsx new file mode 100644 index 00000000..c22b77ea --- /dev/null +++ b/apps/aevatar-console-web/src/pages/primitives/index.test.tsx @@ -0,0 +1,48 @@ +import { screen } from "@testing-library/react"; +import React from "react"; +import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; +import PrimitivesPage from "./index"; + +jest.mock("@/shared/api/runtimeQueryApi", () => ({ + runtimeQueryApi: { + listPrimitives: jest.fn(async () => [ + { + name: "human_input", + category: "interaction", + description: "Pause the workflow and request human input.", + aliases: ["humanApproval"], + parameters: [ + { + name: "prompt", + type: "string", + required: true, + default: "", + enumValues: [], + description: "Prompt shown to the human operator.", + }, + ], + exampleWorkflows: ["incident_triage"], + }, + ]), + }, +})); + +describe("PrimitivesPage", () => { + it("keeps primitive examples inside runtime and scope surfaces", async () => { + const { container } = renderWithQueryClient( + React.createElement(PrimitivesPage) + ); + + expect(container.textContent).toContain("Runtime Primitives"); + expect(container.textContent).toContain( + "Browse the backend-authored runtime primitive view, including normalized parameters and example workflow references." + ); + expect( + await screen.findByRole("button", { name: "Inspect runtime" }) + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Run" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Scope assets" })).toBeTruthy(); + expect(container.textContent).not.toContain("Legacy draft"); + expect(container.textContent).not.toContain("Studio"); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/primitives/index.tsx b/apps/aevatar-console-web/src/pages/primitives/index.tsx index 48a2c902..4667d39b 100644 --- a/apps/aevatar-console-web/src/pages/primitives/index.tsx +++ b/apps/aevatar-console-web/src/pages/primitives/index.tsx @@ -4,13 +4,13 @@ import { ProDescriptions, ProList, ProTable, -} from '@ant-design/pro-components'; +} from "@ant-design/pro-components"; import type { ProColumns, ProDescriptionsItemProps, -} from '@ant-design/pro-components'; -import { useQuery } from '@tanstack/react-query'; -import { history } from '@umijs/max'; +} from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; import { Alert, Button, @@ -22,15 +22,13 @@ import { Space, Tag, Typography, -} from 'antd'; -import React, { useEffect, useMemo, useState } from 'react'; -import { consoleApi } from '@/shared/api/consoleApi'; +} from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { runtimeQueryApi } from "@/shared/api/runtimeQueryApi"; import type { WorkflowPrimitiveDescriptor, WorkflowPrimitiveParameterDescriptor, -} from '@/shared/api/models'; -import { buildPlaygroundRoute } from '@/shared/playground/navigation'; -import { buildStudioRoute } from '@/shared/studio/navigation'; +} from "@/shared/models/runtime/query"; import { cardStackStyle, compactTableCardProps, @@ -39,7 +37,7 @@ import { moduleCardProps, scrollPanelStyle, stretchColumnStyle, -} from '@/shared/ui/proComponents'; +} from "@/shared/ui/proComponents"; type PrimitiveLibraryRow = WorkflowPrimitiveDescriptor & { key: string; @@ -56,86 +54,89 @@ type PrimitiveSummaryRecord = { }; function readInitialPrimitiveSelection(): string { - if (typeof window === 'undefined') { - return ''; + if (typeof window === "undefined") { + return ""; } - return new URLSearchParams(window.location.search).get('primitive')?.trim() ?? ''; + return ( + new URLSearchParams(window.location.search).get("primitive")?.trim() ?? "" + ); } -const primitiveSummaryColumns: ProDescriptionsItemProps[] = [ - { - title: 'Category', - dataIndex: 'category', - }, - { - title: 'Aliases', - dataIndex: 'aliasCount', - valueType: 'digit', - }, - { - title: 'Parameters', - dataIndex: 'parameterCount', - valueType: 'digit', - }, - { - title: 'Example workflows', - dataIndex: 'exampleWorkflowCount', - valueType: 'digit', - }, -]; +const primitiveSummaryColumns: ProDescriptionsItemProps[] = + [ + { + title: "Category", + dataIndex: "category", + }, + { + title: "Aliases", + dataIndex: "aliasCount", + valueType: "digit", + }, + { + title: "Parameters", + dataIndex: "parameterCount", + valueType: "digit", + }, + { + title: "Example workflows", + dataIndex: "exampleWorkflowCount", + valueType: "digit", + }, + ]; const parameterColumns: ProColumns[] = [ { - title: 'Name', - dataIndex: 'name', + title: "Name", + dataIndex: "name", width: 180, }, { - title: 'Type', - dataIndex: 'type', + title: "Type", + dataIndex: "type", width: 120, }, { - title: 'Required', - dataIndex: 'required', + title: "Required", + dataIndex: "required", width: 120, render: (_, record) => ( - - {record.required ? 'Required' : 'Optional'} + + {record.required ? "Required" : "Optional"} ), }, { - title: 'Default', - dataIndex: 'default', + title: "Default", + dataIndex: "default", width: 180, - render: (_, record) => record.default || 'n/a', + render: (_, record) => record.default || "n/a", }, { - title: 'Enum', - dataIndex: 'enumValues', + title: "Enum", + dataIndex: "enumValues", width: 180, render: (_, record) => - record.enumValues.length > 0 ? record.enumValues.join(', ') : 'n/a', + record.enumValues.length > 0 ? record.enumValues.join(", ") : "n/a", }, { - title: 'Description', - dataIndex: 'description', + title: "Description", + dataIndex: "description", ellipsis: true, }, ]; const PrimitivesPage: React.FC = () => { - const [keyword, setKeyword] = useState(''); + const [keyword, setKeyword] = useState(""); const [selectedCategories, setSelectedCategories] = useState([]); const [selectedPrimitiveName, setSelectedPrimitiveName] = useState( - readInitialPrimitiveSelection(), + readInitialPrimitiveSelection() ); const primitivesQuery = useQuery({ - queryKey: ['primitive-library'], - queryFn: () => consoleApi.listPrimitives(), + queryKey: ["primitive-library"], + queryFn: () => runtimeQueryApi.listPrimitives(), }); const primitiveRows = useMemo( @@ -144,11 +145,11 @@ const PrimitivesPage: React.FC = () => { ...primitive, key: primitive.name, aliasSummary: - primitive.aliases.length > 0 ? primitive.aliases.join(', ') : 'n/a', + primitive.aliases.length > 0 ? primitive.aliases.join(", ") : "n/a", parameterCount: primitive.parameters.length, exampleWorkflowCount: primitive.exampleWorkflows.length, })), - [primitivesQuery.data], + [primitivesQuery.data] ); const categoryOptions = useMemo( @@ -156,7 +157,7 @@ const PrimitivesPage: React.FC = () => { Array.from(new Set(primitiveRows.map((item) => item.category))) .sort((left, right) => left.localeCompare(right)) .map((category) => ({ label: category, value: category })), - [primitiveRows], + [primitiveRows] ); const filteredRows = useMemo(() => { @@ -174,13 +175,8 @@ const PrimitivesPage: React.FC = () => { return true; } - return [ - item.name, - item.category, - item.description, - item.aliasSummary, - ] - .join(' ') + return [item.name, item.category, item.description, item.aliasSummary] + .join(" ") .toLowerCase() .includes(normalizedKeyword); }); @@ -188,7 +184,7 @@ const PrimitivesPage: React.FC = () => { useEffect(() => { if (filteredRows.length === 0) { - setSelectedPrimitiveName(''); + setSelectedPrimitiveName(""); return; } @@ -201,22 +197,22 @@ const PrimitivesPage: React.FC = () => { }, [filteredRows, selectedPrimitiveName]); useEffect(() => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return; } const url = new URL(window.location.href); if (selectedPrimitiveName) { - url.searchParams.set('primitive', selectedPrimitiveName); + url.searchParams.set("primitive", selectedPrimitiveName); } else { - url.searchParams.delete('primitive'); + url.searchParams.delete("primitive"); } - window.history.replaceState(null, '', `${url.pathname}${url.search}`); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); }, [selectedPrimitiveName]); const selectedPrimitive = useMemo( () => primitiveRows.find((item) => item.name === selectedPrimitiveName), - [primitiveRows, selectedPrimitiveName], + [primitiveRows, selectedPrimitiveName] ); const summaryRecord = useMemo(() => { @@ -235,49 +231,53 @@ const PrimitivesPage: React.FC = () => { const libraryColumns = useMemo[]>( () => [ { - title: 'Primitive', - dataIndex: 'name', + title: "Primitive", + dataIndex: "name", width: 180, }, { - title: 'Category', - dataIndex: 'category', + title: "Category", + dataIndex: "category", width: 140, }, { - title: 'Aliases', - dataIndex: 'aliasSummary', + title: "Aliases", + dataIndex: "aliasSummary", ellipsis: true, }, { - title: 'Params', - dataIndex: 'parameterCount', + title: "Params", + dataIndex: "parameterCount", width: 100, - valueType: 'digit', + valueType: "digit", }, { - title: 'Examples', - dataIndex: 'exampleWorkflowCount', + title: "Examples", + dataIndex: "exampleWorkflowCount", width: 100, - valueType: 'digit', + valueType: "digit", }, ], - [], + [] ); return ( - +
@@ -293,7 +293,7 @@ const PrimitivesPage: React.FC = () => { allowClear value={selectedCategories} options={categoryOptions} - style={{ width: '100%' }} + style={{ width: "100%" }} placeholder="Filter categories" onChange={(value) => setSelectedCategories(value)} /> @@ -312,7 +312,9 @@ const PrimitivesPage: React.FC = () => { onClick: () => setSelectedPrimitiveName(record.name), })} rowClassName={(record) => - record.name === selectedPrimitiveName ? 'ant-table-row-selected' : '' + record.name === selectedPrimitiveName + ? "ant-table-row-selected" + : "" } locale={{ emptyText: ( @@ -332,7 +334,7 @@ const PrimitivesPage: React.FC = () => { title={ selectedPrimitive ? `Primitive detail · ${selectedPrimitive.name}` - : 'Primitive detail' + : "Primitive detail" } {...moduleCardProps} style={fillCardStyle} @@ -341,7 +343,7 @@ const PrimitivesPage: React.FC = () => { ) : !selectedPrimitive ? ( @@ -400,16 +402,18 @@ const PrimitivesPage: React.FC = () => { rowKey="name" search={false} split - dataSource={selectedPrimitive.exampleWorkflows.map((name) => ({ - name, - }))} + dataSource={selectedPrimitive.exampleWorkflows.map( + (name) => ({ + name, + }) + )} metas={{ title: { - dataIndex: 'name', + dataIndex: "name", }, description: { render: (_, record) => - `Open ${record.name} in the workflow library or bring it into Studio as a workflow template.`, + `Open ${record.name} in the runtime workflow library, launch a run, or jump to scope-owned published assets.`, }, content: { render: (_, record) => ( @@ -419,37 +423,32 @@ const PrimitivesPage: React.FC = () => { onClick={() => history.push( `/workflows?workflow=${encodeURIComponent( - record.name, - )}&tab=yaml`, + record.name + )}&tab=yaml` ) } > - Inspect + Inspect runtime ), diff --git a/apps/aevatar-console-web/src/pages/runs/index.test.tsx b/apps/aevatar-console-web/src/pages/runs/index.test.tsx new file mode 100644 index 00000000..29b4b08a --- /dev/null +++ b/apps/aevatar-console-web/src/pages/runs/index.test.tsx @@ -0,0 +1,84 @@ +import { screen } from "@testing-library/react"; +import React from "react"; +import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; +import RunsPage from "./index"; + +jest.mock("@aevatar-react-sdk/agui", () => ({ + connectChatWebSocket: jest.fn(), + parseSSEStream: jest.fn(), + useHumanInteraction: jest.fn(() => ({ + resume: jest.fn(), + signal: jest.fn(), + resuming: false, + signaling: false, + })), + useRunSession: jest.fn(() => ({ + session: { + context: undefined, + status: "idle", + messages: [], + events: [], + activeSteps: new Set(), + pendingHumanInput: undefined, + runId: "", + error: undefined, + }, + dispatch: jest.fn(), + reset: jest.fn(), + })), +})); + +jest.mock("@aevatar-react-sdk/types", () => ({ + AGUIEventType: { + RUN_ERROR: "RUN_ERROR", + }, + CustomEventName: { + WaitingSignal: "WaitingSignal", + StepRequest: "StepRequest", + }, +})); + +jest.mock("@/shared/api/runtimeCatalogApi", () => ({ + runtimeCatalogApi: { + listWorkflowCatalog: jest.fn(async () => []), + }, +})); + +jest.mock("@/shared/api/runtimeActorsApi", () => ({ + runtimeActorsApi: { + getActorSnapshot: jest.fn(), + }, +})); + +jest.mock("@/shared/api/runtimeRunsApi", () => ({ + runtimeRunsApi: { + streamChat: jest.fn(), + resume: jest.fn(), + signal: jest.fn(), + }, +})); + +describe("RunsPage", () => { + it("renders the runtime run console banner and navigation actions", async () => { + const { container } = renderWithQueryClient(React.createElement(RunsPage)); + + expect(container.textContent).toContain("Runtime run console"); + expect(container.textContent).toContain( + "Drive runtime workflows over /api/chat or /api/ws/chat, monitor the live event stream, and jump into adjacent runtime surfaces directly from the runtime console." + ); + expect( + screen.getByRole("button", { name: "Open workflow catalog" }) + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Open runtime explorer" }) + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Open observability hub" }) + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Open runtime settings" }) + ).toBeTruthy(); + expect(container.textContent).toContain("Composer"); + expect(container.textContent).toContain("Metric HUD"); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/runs/index.tsx b/apps/aevatar-console-web/src/pages/runs/index.tsx index 91264b13..c91270f0 100644 --- a/apps/aevatar-console-web/src/pages/runs/index.tsx +++ b/apps/aevatar-console-web/src/pages/runs/index.tsx @@ -1,10 +1,9 @@ import { connectChatWebSocket, parseSSEStream, - type RunStatus, useHumanInteraction, useRunSession, -} from '@aevatar-react-sdk/agui'; +} from "@aevatar-react-sdk/agui"; import { AGUIEventType, type ChatRunRequest, @@ -12,12 +11,8 @@ import { CustomEventName, type WorkflowResumeRequest, type WorkflowSignalRequest, -} from '@aevatar-react-sdk/types'; -import type { - ProColumns, - ProDescriptionsItemProps, - ProFormInstance, -} from '@ant-design/pro-components'; +} from "@aevatar-react-sdk/types"; +import type { ProFormInstance } from "@ant-design/pro-components"; import { PageContainer, ProCard, @@ -29,9 +24,9 @@ import { ProFormTextArea, ProList, ProTable, -} from '@ant-design/pro-components'; -import { useQuery } from '@tanstack/react-query'; -import { history } from '@umijs/max'; +} from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; import { Alert, Badge, @@ -47,33 +42,35 @@ import { Tabs, Tag, Typography, -} from 'antd'; +} from "antd"; import React, { useCallback, useEffect, useMemo, useRef, useState, -} from 'react'; +} from "react"; import { getLatestCustomEventData, parseStepRequestData, parseWaitingSignalData, -} from '@/shared/agui/customEventData'; -import { consoleApi } from '@/shared/api/consoleApi'; -import { formatDateTime } from '@/shared/datetime/dateTime'; -import { loadConsolePreferences } from '@/shared/preferences/consolePreferences'; +} from "@/shared/agui/customEventData"; +import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; +import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; +import { runtimeRunsApi } from "@/shared/api/runtimeRunsApi"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import { loadConsolePreferences } from "@/shared/preferences/consolePreferences"; import { clearRecentRuns, loadRecentRuns, type RecentRunEntry, saveRecentRun, -} from '@/shared/runs/recentRuns'; +} from "@/shared/runs/recentRuns"; import { buildWorkflowCatalogOptions, findWorkflowCatalogItem, listVisibleWorkflowCatalogItems, -} from '@/shared/workflows/catalogVisibility'; +} from "@/shared/workflows/catalogVisibility"; import { cardStackStyle, compactTableCardProps, @@ -81,650 +78,102 @@ import { moduleCardProps, scrollPanelStyle, stretchColumnStyle, -} from '@/shared/ui/proComponents'; +} from "@/shared/ui/proComponents"; import { buildEventRows, isHumanApprovalSuspension, type RunEventRow, type RunTransport, -} from './runEventPresentation'; - -type RunFormValues = { - prompt: string; - workflow?: string; - actorId?: string; - transport: RunTransport; -}; - -type ResumeFormValues = { - approved: boolean; - userInput?: string; -}; - -type SignalFormValues = { - payload?: string; -}; - -type RunPreset = { - key: string; - title: string; - workflow: string; - prompt: string; - description: string; - tags: string[]; -}; - -type RunStatusValue = RunStatus | 'unknown'; -type RunFocusStatus = - | 'idle' - | 'running' - | 'human_input' - | 'human_approval' - | 'wait_signal' - | 'finished' - | 'error'; - -type RecentRunRow = RecentRunEntry & { - key: string; - statusValue: RunStatusValue; -}; - -type RecentRunTableRow = RecentRunRow & { - onRestore?: () => void; - onOpenActor?: () => void; -}; - -type RunSummaryRecord = { - status: RunStatus; - transport: RunTransport; - workflowName: string; - actorId: string; - commandId: string; - runId: string; - focusStatus: RunFocusStatus; - focusLabel: string; - lastEventAt: string; - messageCount: number; - eventCount: number; - activeSteps: string[]; -}; - -type SelectedWorkflowRecord = { - workflowName: string; - groupLabel: string; - sourceLabel: string; - llmStatus: 'processing' | 'success'; - description: string; -}; - -type WaitingSignalRecord = { - signalName: string; - stepId: string; - runId: string; - prompt: string; -}; - -type HumanInputRecord = { - stepId: string; - runId: string; - suspensionType: string; - prompt: string; - timeoutSeconds: number; -}; - -type ConsoleViewKey = 'dual' | 'messages' | 'events'; - -const composerRailMinWidth = 320; -const composerRailDefaultWidth = 360; -const composerRailMaxWidth = 560; -const composerRailKeyboardStep = 24; -const monitorWorkbenchMinWidth = 520; - -const builtInPresets: RunPreset[] = [ - { - key: 'direct', - title: 'Direct chat', - workflow: 'direct', - prompt: - 'Summarize what this workflow can do and produce a concise execution result.', - description: - 'Baseline direct workflow for quick validation of the chat stream.', - tags: ['baseline', 'llm'], - }, - { - key: 'human-input', - title: 'Human input triage', - workflow: 'human_input_manual_triage', - prompt: - 'A production incident needs manual classification before the workflow can continue.', - description: 'Use this to verify human input prompts and resume flow.', - tags: ['human_input', 'resume'], - }, - { - key: 'human-approval', - title: 'Human approval gate', - workflow: 'human_approval_release_gate', - prompt: - 'Prepare a release summary that requires explicit human approval before rollout.', - description: 'Use this to verify approval flow and moderation checkpoints.', - tags: ['human_approval', 'approval'], - }, - { - key: 'wait-signal', - title: 'Wait signal', - workflow: 'wait_signal_manual_success', - prompt: 'Wait for an external readiness signal before completing the run.', - description: - 'Use this to verify waiting_signal and manual signal delivery.', - tags: ['wait_signal', 'signal'], - }, -]; - -const runStatusValueEnum = { - idle: { text: 'Idle', status: 'Default' }, - running: { text: 'Running', status: 'Processing' }, - finished: { text: 'Finished', status: 'Success' }, - error: { text: 'Error', status: 'Error' }, - unknown: { text: 'Unknown', status: 'Default' }, -} as const; - -const transportValueEnum = { - sse: { text: 'SSE', status: 'Processing' }, - ws: { text: 'WebSocket', status: 'Success' }, -} as const; - -const runFocusValueEnum = { - idle: { text: 'Idle', status: 'Default' }, - running: { text: 'Running', status: 'Processing' }, - human_input: { text: 'Human input', status: 'Warning' }, - human_approval: { text: 'Approval', status: 'Warning' }, - wait_signal: { text: 'Wait signal', status: 'Warning' }, - finished: { text: 'Finished', status: 'Success' }, - error: { text: 'Error', status: 'Error' }, -} as const; - -const runSummaryColumns: ProDescriptionsItemProps[] = [ - { - title: 'Transport', - dataIndex: 'transport', - valueType: 'status' as any, - valueEnum: transportValueEnum, - }, - { - title: 'Workflow', - dataIndex: 'workflowName', - render: (_, record) => record.workflowName || 'n/a', - }, - { - title: 'Actor', - dataIndex: 'actorId', - render: (_, record) => - record.actorId ? ( - {record.actorId} - ) : ( - 'n/a' - ), - }, - { - title: 'Command', - dataIndex: 'commandId', - render: (_, record) => - record.commandId ? ( - {record.commandId} - ) : ( - 'n/a' - ), - }, - { - title: 'RunId', - dataIndex: 'runId', - render: (_, record) => - record.runId ? ( - {record.runId} - ) : ( - 'n/a' - ), - }, - { - title: 'Current focus', - dataIndex: 'focusStatus', - valueType: 'status' as any, - valueEnum: runFocusValueEnum, - render: (_, record) => {record.focusLabel}, - }, - { - title: 'Last event', - dataIndex: 'lastEventAt', - valueType: 'dateTime', - render: (_, record) => record.lastEventAt || 'n/a', - }, - { - title: 'Active steps', - dataIndex: 'activeSteps', - render: (_, record) => - record.activeSteps.length > 0 ? ( - - {record.activeSteps.map((step) => ( - - {step} - - ))} - - ) : ( - None - ), - }, -]; - -const humanInputColumns: ProDescriptionsItemProps[] = [ - { - title: 'Step', - dataIndex: 'stepId', - render: (_, record) => record.stepId || 'n/a', - }, - { - title: 'Run', - dataIndex: 'runId', - render: (_, record) => record.runId || 'n/a', - }, - { - title: 'Suspension', - dataIndex: 'suspensionType', - render: (_, record) => record.suspensionType || 'n/a', - }, - { - title: 'Timeout', - dataIndex: 'timeoutSeconds', - valueType: 'digit', - }, - { - title: 'Prompt', - dataIndex: 'prompt', - render: (_, record) => record.prompt || 'n/a', - }, -]; - -const workflowDescriptionColumns: ProDescriptionsItemProps[] = - [ - { - title: 'Workflow', - dataIndex: 'workflowName', - render: (_, record) => ( - {record.workflowName} - ), - }, - { - title: 'Group', - dataIndex: 'groupLabel', - }, - { - title: 'Source', - dataIndex: 'sourceLabel', - }, - { - title: 'LLM', - dataIndex: 'llmStatus', - valueType: 'status' as any, - valueEnum: { - processing: { text: 'Required', status: 'Processing' }, - success: { text: 'Optional', status: 'Success' }, - }, - }, - { - title: 'Description', - dataIndex: 'description', - }, - ]; - -const waitingSignalColumns: ProDescriptionsItemProps[] = [ - { - title: 'Signal name', - dataIndex: 'signalName', - }, - { - title: 'Step', - dataIndex: 'stepId', - render: (_, record) => record.stepId || 'n/a', - }, - { - title: 'Run', - dataIndex: 'runId', - render: (_, record) => record.runId || 'n/a', - }, - { - title: 'Prompt', - dataIndex: 'prompt', - render: (_, record) => record.prompt || 'n/a', - }, -]; - -const runsWorkbenchShellStyle = { - background: - 'linear-gradient(180deg, rgba(15, 23, 42, 0.03) 0%, rgba(15, 23, 42, 0.01) 100%)', - display: 'flex', - flexDirection: 'column', - gap: 12, - height: 'calc(100vh - 64px)', - overflow: 'hidden', - padding: 12, - position: 'relative', -} as const; - -const runsWorkbenchHeaderStyle = { - alignItems: 'center', - backdropFilter: 'blur(8px)', - background: 'var(--ant-color-bg-container)', - border: '1px solid var(--ant-color-border-secondary)', - borderRadius: 14, - display: 'flex', - flex: '0 0 auto', - justifyContent: 'space-between', - minHeight: 52, - padding: '0 16px', - position: 'sticky', - top: 0, - zIndex: 6, -} as const; - -const runsWorkbenchMainStyle = { - display: 'flex', - flex: 1, - minHeight: 0, - overflow: 'hidden', -} as const; - -const runsWorkbenchComposerRailStyle = { - display: 'flex', - minWidth: 0, - overflow: 'hidden', -} as const; - -const runsWorkbenchResizeRailStyle = { - alignItems: 'stretch', - background: 'transparent', - border: 'none', - cursor: 'col-resize', - display: 'flex', - flex: '0 0 20px', - justifyContent: 'center', - outline: 'none', - padding: '0 6px', - userSelect: 'none', -} as const; - -const runsWorkbenchResizeHandleStyle = { - background: 'var(--ant-color-border-secondary)', - borderRadius: 999, - transition: 'background-color 0.2s ease, transform 0.2s ease', - width: 4, -} as const; - -const runsWorkbenchMonitorStyle = { - display: 'flex', - flex: 1, - flexDirection: 'column', - gap: 12, - minWidth: 0, - overflow: 'hidden', -} as const; - -const workbenchCardStyle = { - display: 'flex', - flex: 1, - flexDirection: 'column', - minHeight: 0, -} as const; - -const workbenchCardBodyStyle = { - display: 'flex', - flex: 1, - flexDirection: 'column', - minHeight: 0, - overflow: 'hidden', - padding: 12, -} as const; - -const workbenchScrollableBodyStyle = { - flex: 1, - minHeight: 0, - overflowX: 'hidden', - overflowY: 'auto', - paddingRight: 4, -} as const; - -const workbenchHudCardStyle = { - ...workbenchCardStyle, - flex: '0 0 auto', -} as const; - -const workbenchHudBodyStyle = { - ...workbenchCardBodyStyle, - overflow: 'visible', -} as const; - -const workbenchOverviewGridStyle = { - flex: 1, - minHeight: 0, -} as const; - -const workbenchOverviewCardStyle = { - ...workbenchCardStyle, - minHeight: 0, -} as const; - -const workbenchConsoleCardStyle = { - ...workbenchCardStyle, - flex: '0 0 calc((100vh - 64px) * 0.3)', - minHeight: 260, -} as const; - -const workbenchConsoleBodyStyle = { - ...workbenchCardBodyStyle, - overflow: 'hidden', -} as const; - -const workbenchConsoleViewportStyle = { - display: 'flex', - flex: 1, - flexDirection: 'column', - minHeight: 0, -} as const; - -const workbenchConsoleTabPanelStyle = { - display: 'flex', - flexDirection: 'column', - height: 'calc((100vh - 64px) * 0.3 - 120px)', - minHeight: 180, -} as const; - -const workbenchConsoleSurfaceStyle = { - background: - 'linear-gradient(180deg, rgba(248, 250, 252, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%)', - border: '1px solid var(--ant-color-border-secondary)', - borderRadius: 12, - color: 'var(--ant-color-text)', - display: 'flex', - flex: 1, - flexDirection: 'column', - fontFamily: - "'Monaco', 'Consolas', 'SFMono-Regular', 'Liberation Mono', monospace", - minHeight: 0, - overflow: 'hidden', -} as const; - -const workbenchConsoleScrollStyle = { - flex: 1, - minHeight: 0, - overflowX: 'hidden', - overflowY: 'auto', - padding: 12, -} as const; - -const workbenchMessageListStyle = { - display: 'flex', - flexDirection: 'column', - gap: 10, -} as const; - -const workbenchEventHeaderStyle = { - borderBottom: '1px solid var(--ant-color-border-secondary)', - color: 'var(--ant-color-text-secondary)', - display: 'grid', - fontSize: 12, - gap: 12, - gridTemplateColumns: '220px 120px minmax(0, 1fr)', - padding: '12px 12px 8px', -} as const; - -const workbenchEventRowStyle = { - borderBottom: '1px solid rgba(5, 5, 5, 0.06)', - display: 'grid', - gap: 12, - gridTemplateColumns: '220px 120px minmax(0, 1fr)', - padding: '10px 12px', -} as const; - -const recentRunColumns: ProColumns[] = [ - { - title: 'Workflow', - dataIndex: 'workflowName', - ellipsis: true, - }, - { - title: 'Status', - dataIndex: 'statusValue', - width: 120, - valueType: 'status' as any, - valueEnum: runStatusValueEnum, - }, - { - title: 'Recorded', - dataIndex: 'recordedAt', - width: 220, - valueType: 'dateTime', - render: (_, record) => formatDateTime(record.recordedAt), - }, - { - title: 'RunId', - dataIndex: 'runId', - width: 180, - render: (_, record) => record.runId || 'n/a', - }, - { - title: 'Preview', - dataIndex: 'lastMessagePreview', - ellipsis: true, - render: (_, record) => - record.lastMessagePreview || record.prompt || 'No preview recorded.', - }, - { - title: 'Actions', - valueType: 'option', - width: 160, - render: (_, record) => [ - - - {record.actorId ? ( - - ) : null} - , - ], - }, -]; - -function trimOptional(value?: string | null): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - -function formatElapsedDuration(totalMilliseconds: number): string { - const totalSeconds = Math.max(0, Math.floor(totalMilliseconds / 1000)); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return [hours, minutes, seconds] - .map((value) => value.toString().padStart(2, '0')) - .join(':'); - } - - return [minutes, seconds] - .map((value) => value.toString().padStart(2, '0')) - .join(':'); -} - -function clampComposerWidth( - requestedWidth: number, - containerWidth: number, -): number { - const maxWidth = Math.max( - composerRailMinWidth, - Math.min(composerRailMaxWidth, containerWidth - monitorWorkbenchMinWidth), - ); - - return Math.min(Math.max(requestedWidth, composerRailMinWidth), maxWidth); -} - -function readInitialRunFormValues(preferredWorkflow: string): RunFormValues { - if (typeof window === 'undefined') { - return { - prompt: '', - workflow: preferredWorkflow, - actorId: undefined, - transport: 'sse', - }; - } - - const params = new URLSearchParams(window.location.search); - return { - prompt: params.get('prompt') ?? '', - workflow: trimOptional(params.get('workflow')) ?? preferredWorkflow, - actorId: trimOptional(params.get('actorId')), - transport: trimOptional(params.get('transport')) === 'ws' ? 'ws' : 'sse', - }; -} +} from "./runEventPresentation"; +import { + builtInPresets, + clampComposerWidth, + composerRailDefaultWidth, + composerRailKeyboardStep, + composerRailMinWidth, + type ConsoleViewKey, + formatElapsedDuration, + humanInputColumns, + type HumanInputRecord, + monitorWorkbenchMinWidth, + readInitialRunFormValues, + recentRunColumns, + type RecentRunRow, + type RecentRunTableRow, + type ResumeFormValues, + type RunFocusStatus, + type RunFormValues, + type RunPreset, + type RunStatusValue, + runSummaryColumns, + runStatusValueEnum, + runsWorkbenchComposerRailStyle, + runsWorkbenchHeaderStyle, + runsWorkbenchMainStyle, + runsWorkbenchMonitorStyle, + runsWorkbenchResizeHandleStyle, + runsWorkbenchResizeRailStyle, + runsWorkbenchShellStyle, + type RunSummaryRecord, + type SelectedWorkflowRecord, + type SignalFormValues, + waitingSignalColumns, + type WaitingSignalRecord, + workbenchCardStyle, + workbenchCardBodyStyle, + workbenchConsoleBodyStyle, + workbenchConsoleCardStyle, + workbenchConsoleScrollStyle, + workbenchConsoleSurfaceStyle, + workbenchConsoleTabPanelStyle, + workbenchConsoleViewportStyle, + workbenchEventHeaderStyle, + workbenchEventRowStyle, + workbenchHudBodyStyle, + workbenchHudCardStyle, + workbenchMessageListStyle, + workbenchOverviewCardStyle, + workbenchOverviewGridStyle, + workbenchScrollableBodyStyle, + workflowDescriptionColumns, +} from "./runWorkbenchConfig"; const RunsPage: React.FC = () => { const preferences = useMemo(() => loadConsolePreferences(), []); const [messageApi, messageContextHolder] = message.useMessage(); const initialFormValues = useMemo( () => readInitialRunFormValues(preferences.preferredWorkflow), - [preferences.preferredWorkflow], + [preferences.preferredWorkflow] ); const composerFormRef = useRef | undefined>( - undefined, + undefined ); const runsWorkbenchMainRef = useRef(null); const resumeFormRef = useRef | undefined>( - undefined, + undefined ); const signalFormRef = useRef | undefined>( - undefined, + undefined ); - const [catalogSearch, setCatalogSearch] = useState(''); + const [catalogSearch, setCatalogSearch] = useState(""); const [selectedWorkflowName, setSelectedWorkflowName] = useState( - initialFormValues.workflow ?? preferences.preferredWorkflow, + initialFormValues.workflow ?? preferences.preferredWorkflow ); const [recentRuns, setRecentRuns] = useState(() => - loadRecentRuns(), + loadRecentRuns() ); const [selectedTransport, setSelectedTransport] = useState( - initialFormValues.transport, + initialFormValues.transport ); const [composerWidth, setComposerWidth] = useState(composerRailDefaultWidth); const [activeTransport, setActiveTransport] = useState( - initialFormValues.transport, + initialFormValues.transport ); - const [consoleView, setConsoleView] = useState('dual'); + const [consoleView, setConsoleView] = useState("dual"); const [isInteractionDrawerOpen, setIsInteractionDrawerOpen] = useState(false); const [isComposerResizing, setIsComposerResizing] = useState(false); const [runStartedAtMs, setRunStartedAtMs] = useState( - undefined, + undefined ); const [elapsedNow, setElapsedNow] = useState(() => Date.now()); const [transportIssue, setTransportIssue] = useState< @@ -734,8 +183,8 @@ const RunsPage: React.FC = () => { const stopActiveRunRef = useRef<(() => void) | undefined>(undefined); const workflowCatalogQuery = useQuery({ - queryKey: ['workflow-catalog'], - queryFn: () => consoleApi.listWorkflowCatalog(), + queryKey: ["workflow-catalog"], + queryFn: () => runtimeCatalogApi.listWorkflowCatalog(), }); const { session, dispatch, reset } = useRunSession(); @@ -758,7 +207,7 @@ const RunsPage: React.FC = () => { }); messageApi.error(code ? `${code}: ${messageText}` : messageText); }, - [dispatch, messageApi], + [dispatch, messageApi] ); const sendRun = useCallback( @@ -772,7 +221,7 @@ const RunsPage: React.FC = () => { setStreaming(true); try { - if (transport === 'ws') { + if (transport === "ws") { const { events, close } = connectChatWebSocket(request, { onAck: (payload) => { setWsAck(payload); @@ -791,9 +240,9 @@ const RunsPage: React.FC = () => { const controller = new AbortController(); stopActiveRunRef.current = () => controller.abort(); - const response = await consoleApi.streamChat( + const response = await runtimeRunsApi.streamChat( request, - controller.signal, + controller.signal ); for await (const event of parseSSEStream(response, { signal: controller.signal, @@ -806,7 +255,7 @@ const RunsPage: React.FC = () => { } } } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { + if (error instanceof Error && error.name === "AbortError") { return; } @@ -817,7 +266,7 @@ const RunsPage: React.FC = () => { setStreaming(false); } }, - [abortRun, dispatch, reportTransportError, reset], + [abortRun, dispatch, reportTransportError, reset] ); const resizeComposerRail = useCallback((clientX: number) => { @@ -827,7 +276,7 @@ const RunsPage: React.FC = () => { } setComposerWidth( - clampComposerWidth(clientX - containerRect.left, containerRect.width), + clampComposerWidth(clientX - containerRect.left, containerRect.width) ); }, []); @@ -842,8 +291,8 @@ const RunsPage: React.FC = () => { }, []); const { resume, signal, resuming, signaling } = useHumanInteraction({ - resume: (request: WorkflowResumeRequest) => consoleApi.resume(request), - signal: (request: WorkflowSignalRequest) => consoleApi.signal(request), + resume: (request: WorkflowResumeRequest) => runtimeRunsApi.resume(request), + signal: (request: WorkflowSignalRequest) => runtimeRunsApi.signal(request), }); useEffect(() => () => abortRun(), [abortRun]); @@ -857,14 +306,14 @@ const RunsPage: React.FC = () => { } setComposerWidth((currentWidth) => - clampComposerWidth(currentWidth, containerRect.width), + clampComposerWidth(currentWidth, containerRect.width) ); }; syncComposerWidth(); - window.addEventListener('resize', syncComposerWidth); + window.addEventListener("resize", syncComposerWidth); return () => { - window.removeEventListener('resize', syncComposerWidth); + window.removeEventListener("resize", syncComposerWidth); }; }, []); @@ -880,66 +329,68 @@ const RunsPage: React.FC = () => { setIsComposerResizing(false); }; - window.addEventListener('pointermove', handlePointerMove); - window.addEventListener('pointerup', handlePointerUp); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; return () => { - window.removeEventListener('pointermove', handlePointerMove); - window.removeEventListener('pointerup', handlePointerUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; }; }, [isComposerResizing, resizeComposerRail]); const workflowName = session.context?.workflowName ?? wsAck?.workflow ?? - composerFormRef.current?.getFieldValue('workflow') ?? - ''; + composerFormRef.current?.getFieldValue("workflow") ?? + ""; const actorId = session.context?.actorId ?? wsAck?.actorId; - const commandId = session.context?.commandId ?? wsAck?.commandId ?? ''; + const commandId = session.context?.commandId ?? wsAck?.commandId ?? ""; const waitingSignal = useMemo( () => getLatestCustomEventData( session.events, CustomEventName.WaitingSignal, - parseWaitingSignalData, + parseWaitingSignalData ), - [session.events], + [session.events] ); const latestStepRequest = useMemo( () => getLatestCustomEventData( session.events, CustomEventName.StepRequest, - parseStepRequestData, + parseStepRequestData ), - [session.events], + [session.events] ); const actorSnapshotQuery = useQuery({ - queryKey: ['run-actor-snapshot', actorId], + queryKey: ["run-actor-snapshot", actorId], enabled: Boolean(actorId), - queryFn: () => consoleApi.getActorSnapshot(actorId || ''), + queryFn: () => runtimeActorsApi.getActorSnapshot(actorId || ""), refetchInterval: - actorId && (streaming || session.status === 'running') ? 2_000 : false, + actorId && (streaming || session.status === "running") ? 2_000 : false, }); const filteredCatalog = useMemo(() => { const keyword = catalogSearch.trim().toLowerCase(); - const items = listVisibleWorkflowCatalogItems(workflowCatalogQuery.data ?? []); + const items = listVisibleWorkflowCatalogItems( + workflowCatalogQuery.data ?? [] + ); if (!keyword) { return items; } return items.filter((item) => [item.name, item.description, item.groupLabel, item.category] - .join(' ') + .join(" ") .toLowerCase() - .includes(keyword), + .includes(keyword) ); }, [catalogSearch, workflowCatalogQuery.data]); @@ -947,16 +398,20 @@ const RunsPage: React.FC = () => { const visibleNames = new Set(filteredCatalog.map((item) => item.name)); return buildWorkflowCatalogOptions( workflowCatalogQuery.data ?? [], - selectedWorkflowName, + selectedWorkflowName ).filter( - (option) => option.value === selectedWorkflowName || visibleNames.has(option.value), + (option) => + option.value === selectedWorkflowName || visibleNames.has(option.value) ); }, [filteredCatalog, selectedWorkflowName, workflowCatalogQuery.data]); const selectedWorkflowDetails = useMemo( () => - findWorkflowCatalogItem(workflowCatalogQuery.data ?? [], selectedWorkflowName), - [selectedWorkflowName, workflowCatalogQuery.data], + findWorkflowCatalogItem( + workflowCatalogQuery.data ?? [], + selectedWorkflowName + ), + [selectedWorkflowName, workflowCatalogQuery.data] ); const selectedWorkflowRecord = useMemo< @@ -971,8 +426,8 @@ const RunsPage: React.FC = () => { groupLabel: selectedWorkflowDetails.groupLabel, sourceLabel: selectedWorkflowDetails.sourceLabel, llmStatus: selectedWorkflowDetails.requiresLlmProvider - ? 'processing' - : 'success', + ? "processing" + : "success", description: selectedWorkflowDetails.description, }; }, [selectedWorkflowDetails]); @@ -980,8 +435,8 @@ const RunsPage: React.FC = () => { const visiblePresets = useMemo(() => { const available = new Set( listVisibleWorkflowCatalogItems(workflowCatalogQuery.data ?? []).map( - (item) => item.name, - ), + (item) => item.name + ) ); return builtInPresets.filter((preset) => available.has(preset.workflow)); }, [workflowCatalogQuery.data]); @@ -990,7 +445,7 @@ const RunsPage: React.FC = () => { const lastWithContent = [...session.messages] .reverse() .find((item) => item.content?.trim()); - return lastWithContent?.content?.trim() ?? ''; + return lastWithContent?.content?.trim() ?? ""; }, [session.messages]); const recentRunRows = useMemo( @@ -998,11 +453,11 @@ const RunsPage: React.FC = () => { recentRuns.map((entry) => ({ ...entry, key: entry.id, - statusValue: ['idle', 'running', 'finished', 'error'].includes( - entry.status, + statusValue: ["idle", "running", "finished", "error"].includes( + entry.status ) - ? (entry.status as RunStatus) - : 'unknown', + ? (entry.status as RunStatusValue) + : "unknown", onRestore: () => { composerFormRef.current?.setFieldsValue({ prompt: entry.prompt, @@ -1015,16 +470,16 @@ const RunsPage: React.FC = () => { onOpenActor: entry.actorId ? () => history.push( - `/actors?actorId=${encodeURIComponent(entry.actorId)}`, + `/actors?actorId=${encodeURIComponent(entry.actorId)}` ) : undefined, })), - [recentRuns, selectedTransport], + [recentRuns, selectedTransport] ); const eventRows = useMemo( () => buildEventRows(session.events), - [session.events], + [session.events] ); const waitingSignalRecord = useMemo(() => { if (!waitingSignal) { @@ -1032,10 +487,10 @@ const RunsPage: React.FC = () => { } return { - signalName: waitingSignal.signalName ?? '', - stepId: waitingSignal.stepId ?? '', - runId: waitingSignal.runId ?? '', - prompt: waitingSignal.prompt ?? '', + signalName: waitingSignal.signalName ?? "", + stepId: waitingSignal.stepId ?? "", + runId: waitingSignal.runId ?? "", + prompt: waitingSignal.prompt ?? "", }; }, [waitingSignal]); @@ -1046,13 +501,13 @@ const RunsPage: React.FC = () => { return { stepId: - session.pendingHumanInput.stepId ?? latestStepRequest?.stepId ?? '', - runId: session.pendingHumanInput.runId ?? session.runId ?? '', + session.pendingHumanInput.stepId ?? latestStepRequest?.stepId ?? "", + runId: session.pendingHumanInput.runId ?? session.runId ?? "", suspensionType: session.pendingHumanInput.suspensionType ?? latestStepRequest?.stepType ?? - '', - prompt: session.pendingHumanInput.prompt ?? '', + "", + prompt: session.pendingHumanInput.prompt ?? "", timeoutSeconds: session.pendingHumanInput.timeoutSeconds ?? 0, }; }, [ @@ -1063,76 +518,80 @@ const RunsPage: React.FC = () => { ]); const runFocus = useMemo(() => { - if (transportIssue || session.error || session.status === 'error') { + if (transportIssue || session.error || session.status === "error") { return { - status: 'error' as RunFocusStatus, + status: "error" as RunFocusStatus, label: - transportIssue?.message || session.error?.message || 'Run failed', - alertType: 'error' as const, - title: transportIssue?.code ?? session.error?.code ?? 'Run error', + transportIssue?.message || session.error?.message || "Run failed", + alertType: "error" as const, + title: transportIssue?.code ?? session.error?.code ?? "Run error", description: transportIssue?.message || session.error?.message || - 'The run ended with an error.', + "The run ended with an error.", }; } if (humanInputRecord) { const approval = isHumanApprovalSuspension( - humanInputRecord.suspensionType, + humanInputRecord.suspensionType ); return { status: approval - ? ('human_approval' as const) - : ('human_input' as const), + ? ("human_approval" as const) + : ("human_input" as const), label: approval - ? `Awaiting approval on ${humanInputRecord.stepId || 'current step'}` - : `Awaiting human input on ${humanInputRecord.stepId || 'current step'}`, - alertType: 'warning' as const, - title: approval ? 'Approval required' : 'Human input required', + ? `Awaiting approval on ${humanInputRecord.stepId || "current step"}` + : `Awaiting human input on ${ + humanInputRecord.stepId || "current step" + }`, + alertType: "warning" as const, + title: approval ? "Approval required" : "Human input required", description: - humanInputRecord.prompt || 'Operator action is required to continue.', + humanInputRecord.prompt || "Operator action is required to continue.", }; } if (waitingSignalRecord) { return { - status: 'wait_signal' as const, - label: `Waiting for signal ${waitingSignalRecord.signalName || 'unknown'}`, - alertType: 'warning' as const, - title: 'Waiting for external signal', + status: "wait_signal" as const, + label: `Waiting for signal ${ + waitingSignalRecord.signalName || "unknown" + }`, + alertType: "warning" as const, + title: "Waiting for external signal", description: waitingSignalRecord.prompt || - 'The workflow is paused until the expected signal arrives.', + "The workflow is paused until the expected signal arrives.", }; } - if (streaming || session.status === 'running') { + if (streaming || session.status === "running") { return { - status: 'running' as const, + status: "running" as const, label: `Streaming over ${activeTransport.toUpperCase()}`, - alertType: 'info' as const, - title: 'Run in progress', - description: 'Messages and events are still arriving from the backend.', + alertType: "info" as const, + title: "Run in progress", + description: "Messages and events are still arriving from the backend.", }; } - if (session.status === 'finished') { + if (session.status === "finished") { return { - status: 'finished' as const, - label: 'Run completed', - alertType: 'success' as const, - title: 'Run finished', - description: 'The backend reported a completed run.', + status: "finished" as const, + label: "Run completed", + alertType: "success" as const, + title: "Run finished", + description: "The backend reported a completed run.", }; } return { - status: 'idle' as const, - label: 'Ready to start a run', - alertType: 'info' as const, - title: 'Idle', - description: 'Compose a prompt and start a workflow run.', + status: "idle" as const, + label: "Ready to start a run", + alertType: "info" as const, + title: "Idle", + description: "Compose a prompt and start a workflow run.", }; }, [ activeTransport, @@ -1145,15 +604,15 @@ const RunsPage: React.FC = () => { ]); const hasPendingInteraction = Boolean( - humanInputRecord || waitingSignalRecord, + humanInputRecord || waitingSignalRecord ); const runStatusText = runStatusValueEnum[session.status]?.text ?? session.status; const isRunLive = streaming || - session.status === 'running' || + session.status === "running" || hasPendingInteraction || - runFocus.status === 'wait_signal'; + runFocus.status === "wait_signal"; useEffect(() => { if (hasPendingInteraction) { @@ -1185,11 +644,11 @@ const RunsPage: React.FC = () => { const elapsedLabel = runStartedAtMs ? formatElapsedDuration(elapsedNow - runStartedAtMs) - : '00:00'; + : "00:00"; const lastEventAt = useMemo(() => { const latest = session.events[session.events.length - 1]; - return formatDateTime(latest?.timestamp, ''); + return formatDateTime(latest?.timestamp, ""); }, [session.events]); const runSummaryRecord = useMemo( @@ -1197,9 +656,9 @@ const RunsPage: React.FC = () => { status: session.status, transport: activeTransport, workflowName, - actorId: actorId ?? '', + actorId: actorId ?? "", commandId, - runId: session.runId ?? '', + runId: session.runId ?? "", focusStatus: runFocus.status, focusLabel: runFocus.label, lastEventAt, @@ -1220,15 +679,15 @@ const RunsPage: React.FC = () => { session.runId, session.status, workflowName, - ], + ] ); useEffect(() => { - const prompt = composerFormRef.current?.getFieldValue('prompt') ?? ''; + const prompt = composerFormRef.current?.getFieldValue("prompt") ?? ""; const candidateId = commandId ?? session.runId ?? - (actorId && workflowName ? `${workflowName}:${actorId}` : ''); + (actorId && workflowName ? `${workflowName}:${actorId}` : ""); if (!candidateId || (!workflowName && !prompt)) { return; @@ -1239,12 +698,12 @@ const RunsPage: React.FC = () => { id: candidateId, workflowName, prompt, - actorId: actorId ?? '', + actorId: actorId ?? "", commandId, - runId: session.runId ?? '', + runId: session.runId ?? "", status: session.status, lastMessagePreview: latestMessagePreview, - }), + }) ); }, [ actorId, @@ -1259,9 +718,9 @@ const RunsPage: React.FC = () => {
Message stream @@ -1273,55 +732,55 @@ const RunsPage: React.FC = () => {
- } size={8}> + } size={8}> {record.role} {record.messageId} - {record.complete ? 'complete' : 'streaming'} + {record.complete ? "complete" : "streaming"} - {record.content || '(streaming...)'} + {record.content || "(streaming...)"}
))} @@ -1349,25 +808,25 @@ const RunsPage: React.FC = () => {
- {record.timestamp || 'n/a'} + {record.timestamp || "n/a"} {record.eventCategory} {record.eventStatus} @@ -1376,23 +835,23 @@ const RunsPage: React.FC = () => {
{record.eventType} {record.description} - {record.payloadPreview ? `\n${record.payloadPreview}` : ''} + {record.payloadPreview ? `\n${record.payloadPreview}` : ""}
@@ -1408,26 +867,70 @@ const RunsPage: React.FC = () => { ); return ( - + {messageContextHolder}
+ + + + + + + } + />
- } size={16}> + } size={16}> Run ID - {session.runId || commandId || 'Not started'} + {session.runId || commandId || "Not started"} @@ -1436,16 +939,16 @@ const RunsPage: React.FC = () => { Workflow - {workflowName || 'n/a'} + {workflowName || "n/a"} - } size={16}> - + } size={16}> + {activeTransport.toUpperCase()} @@ -1813,12 +1318,12 @@ const RunsPage: React.FC = () => { Status @@ -1834,8 +1339,8 @@ const RunsPage: React.FC = () => { 0 - ? 'processing' - : 'default' + ? "processing" + : "default" } /> Messages @@ -1850,7 +1355,7 @@ const RunsPage: React.FC = () => { 0 ? 'processing' : 'default' + session.events.length > 0 ? "processing" : "default" } /> Events @@ -1865,7 +1370,7 @@ const RunsPage: React.FC = () => { 0 ? 'warning' : 'default' + session.activeSteps.size > 0 ? "warning" : "default" } /> Active steps @@ -1891,7 +1396,7 @@ const RunsPage: React.FC = () => { @@ -1906,8 +1411,8 @@ const RunsPage: React.FC = () => { {latestMessagePreview} @@ -1919,27 +1424,27 @@ const RunsPage: React.FC = () => { showIcon type={ actorSnapshotQuery.data.lastSuccess === false - ? 'error' - : 'success' + ? "error" + : "success" } - message="Latest actor snapshot" + title="Latest actor snapshot" description={ - Output:{' '} - {actorSnapshotQuery.data.lastOutput || 'n/a'} + Output:{" "} + {actorSnapshotQuery.data.lastOutput || "n/a"} - Updated:{' '} + Updated:{" "} {formatDateTime( - actorSnapshotQuery.data.lastUpdatedAt, + actorSnapshotQuery.data.lastUpdatedAt )} @@ -1950,7 +1455,7 @@ const RunsPage: React.FC = () => { ) : null} @@ -1979,7 +1484,7 @@ const RunsPage: React.FC = () => { {selectedWorkflowDetails.primitives.map( (primitive) => ( {primitive} - ), + ) )} ) : null} @@ -2004,7 +1509,7 @@ const RunsPage: React.FC = () => { style={workbenchConsoleCardStyle} bodyStyle={workbenchConsoleBodyStyle} extra={ - } size={12}> + } size={12}> {session.messages.length} messages @@ -2012,7 +1517,7 @@ const RunsPage: React.FC = () => { {eventRows.length} events - {hasPendingInteraction ? 'interaction pending' : 'monitoring'} + {hasPendingInteraction ? "interaction pending" : "monitoring"} } @@ -2022,8 +1527,8 @@ const RunsPage: React.FC = () => { activeKey={consoleView} items={[ { - key: 'dual', - label: 'Dual stream', + key: "dual", + label: "Dual stream", children: (
@@ -2038,8 +1543,8 @@ const RunsPage: React.FC = () => { ), }, { - key: 'messages', - label: 'Messages', + key: "messages", + label: "Messages", children: (
{messageConsoleView} @@ -2047,8 +1552,8 @@ const RunsPage: React.FC = () => { ), }, { - key: 'events', - label: 'Events', + key: "events", + label: "Events", children: (
{eventConsoleView} @@ -2065,14 +1570,14 @@ const RunsPage: React.FC = () => { destroyOnHidden mask={false} open={isInteractionDrawerOpen} - title={hasPendingInteraction ? 'Pending interaction' : 'Interaction'} - width={420} + title={hasPendingInteraction ? "Pending interaction" : "Interaction"} + size={420} onClose={() => setIsInteractionDrawerOpen(false)} >
{humanInputRecord ? (
- + column={1} dataSource={humanInputRecord} @@ -2082,7 +1587,7 @@ const RunsPage: React.FC = () => { key={`${humanInputRecord.runId}-${humanInputRecord.stepId}`} formRef={resumeFormRef} layout="vertical" - initialValues={{ approved: true, userInput: '' }} + initialValues={{ approved: true, userInput: "" }} onFinish={async (values) => { if ( !actorId || @@ -2101,10 +1606,10 @@ const RunsPage: React.FC = () => { commandId, }); - messageApi.success('Resume request accepted.'); + messageApi.success("Resume request accepted."); resumeFormRef.current?.setFieldsValue({ approved: true, - userInput: '', + userInput: "", }); return true; }} @@ -2126,10 +1631,10 @@ const RunsPage: React.FC = () => { name="approved" label={ isHumanApprovalSuspension( - humanInputRecord.suspensionType, + humanInputRecord.suspensionType ) - ? 'Approved' - : 'Continue run' + ? "Approved" + : "Continue run" } /> { {waitingSignalRecord ? (
- + column={1} dataSource={waitingSignalRecord} @@ -2155,7 +1660,7 @@ const RunsPage: React.FC = () => { key={`${waitingSignalRecord.runId}-${waitingSignalRecord.stepId}`} formRef={signalFormRef} layout="vertical" - initialValues={{ payload: '' }} + initialValues={{ payload: "" }} onFinish={async (values) => { if ( !actorId || @@ -2174,8 +1679,8 @@ const RunsPage: React.FC = () => { commandId, }); - messageApi.success('Signal accepted.'); - signalFormRef.current?.setFieldsValue({ payload: '' }); + messageApi.success("Signal accepted."); + signalFormRef.current?.setFieldsValue({ payload: "" }); return true; }} submitter={{ diff --git a/apps/aevatar-console-web/src/pages/runs/runWorkbenchConfig.tsx b/apps/aevatar-console-web/src/pages/runs/runWorkbenchConfig.tsx new file mode 100644 index 00000000..b46e2f2a --- /dev/null +++ b/apps/aevatar-console-web/src/pages/runs/runWorkbenchConfig.tsx @@ -0,0 +1,613 @@ +import type { RunStatus } from "@aevatar-react-sdk/agui"; +import type { + ProColumns, + ProDescriptionsItemProps, +} from "@ant-design/pro-components"; +import { Button, Space, Tag, Typography } from "antd"; +import React from "react"; +import type { RecentRunEntry } from "@/shared/runs/recentRuns"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import type { RunTransport } from "./runEventPresentation"; + +export type RunFormValues = { + prompt: string; + workflow?: string; + actorId?: string; + transport: RunTransport; +}; + +export type ResumeFormValues = { + approved: boolean; + userInput?: string; +}; + +export type SignalFormValues = { + payload?: string; +}; + +export type RunPreset = { + key: string; + title: string; + workflow: string; + prompt: string; + description: string; + tags: string[]; +}; + +export type RunStatusValue = RunStatus | "unknown"; +export type RunFocusStatus = + | "idle" + | "running" + | "human_input" + | "human_approval" + | "wait_signal" + | "finished" + | "error"; + +export type RecentRunRow = RecentRunEntry & { + key: string; + statusValue: RunStatusValue; +}; + +export type RecentRunTableRow = RecentRunRow & { + onRestore?: () => void; + onOpenActor?: () => void; +}; + +export type RunSummaryRecord = { + status: RunStatus; + transport: RunTransport; + workflowName: string; + actorId: string; + commandId: string; + runId: string; + focusStatus: RunFocusStatus; + focusLabel: string; + lastEventAt: string; + messageCount: number; + eventCount: number; + activeSteps: string[]; +}; + +export type SelectedWorkflowRecord = { + workflowName: string; + groupLabel: string; + sourceLabel: string; + llmStatus: "processing" | "success"; + description: string; +}; + +export type WaitingSignalRecord = { + signalName: string; + stepId: string; + runId: string; + prompt: string; +}; + +export type HumanInputRecord = { + stepId: string; + runId: string; + suspensionType: string; + prompt: string; + timeoutSeconds: number; +}; + +export type ConsoleViewKey = "dual" | "messages" | "events"; + +export const composerRailMinWidth = 320; +export const composerRailDefaultWidth = 360; +export const composerRailMaxWidth = 560; +export const composerRailKeyboardStep = 24; +export const monitorWorkbenchMinWidth = 520; + +export const builtInPresets: RunPreset[] = [ + { + key: "direct", + title: "Direct chat", + workflow: "direct", + prompt: + "Summarize what this workflow can do and produce a concise execution result.", + description: + "Baseline direct workflow for quick validation of the chat stream.", + tags: ["baseline", "llm"], + }, + { + key: "human-input", + title: "Human input triage", + workflow: "human_input_manual_triage", + prompt: + "A production incident needs manual classification before the workflow can continue.", + description: "Use this to verify human input prompts and resume flow.", + tags: ["human_input", "resume"], + }, + { + key: "human-approval", + title: "Human approval gate", + workflow: "human_approval_release_gate", + prompt: + "Prepare a release summary that requires explicit human approval before rollout.", + description: "Use this to verify approval flow and moderation checkpoints.", + tags: ["human_approval", "approval"], + }, + { + key: "wait-signal", + title: "Wait signal", + workflow: "wait_signal_manual_success", + prompt: "Wait for an external readiness signal before completing the run.", + description: + "Use this to verify waiting_signal and manual signal delivery.", + tags: ["wait_signal", "signal"], + }, +]; + +export const runStatusValueEnum = { + idle: { text: "Idle", status: "Default" }, + running: { text: "Running", status: "Processing" }, + finished: { text: "Finished", status: "Success" }, + error: { text: "Error", status: "Error" }, + unknown: { text: "Unknown", status: "Default" }, +} as const; + +const transportValueEnum = { + sse: { text: "SSE", status: "Processing" }, + ws: { text: "WebSocket", status: "Success" }, +} as const; + +const runFocusValueEnum = { + idle: { text: "Idle", status: "Default" }, + running: { text: "Running", status: "Processing" }, + human_input: { text: "Human input", status: "Warning" }, + human_approval: { text: "Approval", status: "Warning" }, + wait_signal: { text: "Wait signal", status: "Warning" }, + finished: { text: "Finished", status: "Success" }, + error: { text: "Error", status: "Error" }, +} as const; + +export const runSummaryColumns: ProDescriptionsItemProps[] = [ + { + title: "Transport", + dataIndex: "transport", + valueType: "status" as any, + valueEnum: transportValueEnum, + }, + { + title: "Workflow", + dataIndex: "workflowName", + render: (_, record) => record.workflowName || "n/a", + }, + { + title: "Actor", + dataIndex: "actorId", + render: (_, record) => + record.actorId ? ( + {record.actorId} + ) : ( + "n/a" + ), + }, + { + title: "Command", + dataIndex: "commandId", + render: (_, record) => + record.commandId ? ( + {record.commandId} + ) : ( + "n/a" + ), + }, + { + title: "RunId", + dataIndex: "runId", + render: (_, record) => + record.runId ? ( + {record.runId} + ) : ( + "n/a" + ), + }, + { + title: "Current focus", + dataIndex: "focusStatus", + valueType: "status" as any, + valueEnum: runFocusValueEnum, + render: (_, record) => {record.focusLabel}, + }, + { + title: "Last event", + dataIndex: "lastEventAt", + valueType: "dateTime", + render: (_, record) => record.lastEventAt || "n/a", + }, + { + title: "Active steps", + dataIndex: "activeSteps", + render: (_, record) => + record.activeSteps.length > 0 ? ( + + {record.activeSteps.map((step) => ( + + {step} + + ))} + + ) : ( + None + ), + }, +]; + +export const humanInputColumns: ProDescriptionsItemProps[] = [ + { + title: "Step", + dataIndex: "stepId", + render: (_, record) => record.stepId || "n/a", + }, + { + title: "Run", + dataIndex: "runId", + render: (_, record) => record.runId || "n/a", + }, + { + title: "Suspension", + dataIndex: "suspensionType", + render: (_, record) => record.suspensionType || "n/a", + }, + { + title: "Timeout", + dataIndex: "timeoutSeconds", + valueType: "digit", + }, + { + title: "Prompt", + dataIndex: "prompt", + render: (_, record) => record.prompt || "n/a", + }, +]; + +export const workflowDescriptionColumns: ProDescriptionsItemProps[] = + [ + { + title: "Workflow", + dataIndex: "workflowName", + render: (_, record) => ( + {record.workflowName} + ), + }, + { + title: "Group", + dataIndex: "groupLabel", + }, + { + title: "Source", + dataIndex: "sourceLabel", + }, + { + title: "LLM", + dataIndex: "llmStatus", + valueType: "status" as any, + valueEnum: { + processing: { text: "Required", status: "Processing" }, + success: { text: "Optional", status: "Success" }, + }, + }, + { + title: "Description", + dataIndex: "description", + }, + ]; + +export const waitingSignalColumns: ProDescriptionsItemProps[] = + [ + { + title: "Signal name", + dataIndex: "signalName", + }, + { + title: "Step", + dataIndex: "stepId", + render: (_, record) => record.stepId || "n/a", + }, + { + title: "Run", + dataIndex: "runId", + render: (_, record) => record.runId || "n/a", + }, + { + title: "Prompt", + dataIndex: "prompt", + render: (_, record) => record.prompt || "n/a", + }, + ]; + +export const runsWorkbenchShellStyle = { + background: + "linear-gradient(180deg, rgba(15, 23, 42, 0.03) 0%, rgba(15, 23, 42, 0.01) 100%)", + display: "flex", + flexDirection: "column", + gap: 12, + height: "calc(100vh - 64px)", + overflow: "hidden", + padding: 12, + position: "relative", +} as const; + +export const runsWorkbenchHeaderStyle = { + alignItems: "center", + backdropFilter: "blur(8px)", + background: "var(--ant-color-bg-container)", + border: "1px solid var(--ant-color-border-secondary)", + borderRadius: 14, + display: "flex", + flex: "0 0 auto", + justifyContent: "space-between", + minHeight: 52, + padding: "0 16px", + position: "sticky", + top: 0, + zIndex: 6, +} as const; + +export const runsWorkbenchMainStyle = { + display: "flex", + flex: 1, + minHeight: 0, + overflow: "hidden", +} as const; + +export const runsWorkbenchComposerRailStyle = { + display: "flex", + minWidth: 0, + overflow: "hidden", +} as const; + +export const runsWorkbenchResizeRailStyle = { + alignItems: "stretch", + background: "transparent", + border: "none", + cursor: "col-resize", + display: "flex", + flex: "0 0 20px", + justifyContent: "center", + outline: "none", + padding: "0 6px", + userSelect: "none", +} as const; + +export const runsWorkbenchResizeHandleStyle = { + background: "var(--ant-color-border-secondary)", + borderRadius: 999, + transition: "background-color 0.2s ease, transform 0.2s ease", + width: 4, +} as const; + +export const runsWorkbenchMonitorStyle = { + display: "flex", + flex: 1, + flexDirection: "column", + gap: 12, + minWidth: 0, + overflow: "hidden", +} as const; + +export const workbenchCardStyle = { + display: "flex", + flex: 1, + flexDirection: "column", + minHeight: 0, +} as const; + +export const workbenchCardBodyStyle = { + display: "flex", + flex: 1, + flexDirection: "column", + minHeight: 0, + overflow: "hidden", + padding: 12, +} as const; + +export const workbenchScrollableBodyStyle = { + flex: 1, + minHeight: 0, + overflowX: "hidden", + overflowY: "auto", + paddingRight: 4, +} as const; + +export const workbenchHudCardStyle = { + ...workbenchCardStyle, + flex: "0 0 auto", +} as const; + +export const workbenchHudBodyStyle = { + ...workbenchCardBodyStyle, + overflow: "visible", +} as const; + +export const workbenchOverviewGridStyle = { + flex: 1, + minHeight: 0, +} as const; + +export const workbenchOverviewCardStyle = { + ...workbenchCardStyle, + minHeight: 0, +} as const; + +export const workbenchConsoleCardStyle = { + ...workbenchCardStyle, + flex: "0 0 calc((100vh - 64px) * 0.3)", + minHeight: 260, +} as const; + +export const workbenchConsoleBodyStyle = { + ...workbenchCardBodyStyle, + overflow: "hidden", +} as const; + +export const workbenchConsoleViewportStyle = { + display: "flex", + flex: 1, + flexDirection: "column", + minHeight: 0, +} as const; + +export const workbenchConsoleTabPanelStyle = { + display: "flex", + flexDirection: "column", + height: "calc((100vh - 64px) * 0.3 - 120px)", + minHeight: 180, +} as const; + +export const workbenchConsoleSurfaceStyle = { + background: + "linear-gradient(180deg, rgba(248, 250, 252, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%)", + border: "1px solid var(--ant-color-border-secondary)", + borderRadius: 12, + color: "var(--ant-color-text)", + display: "flex", + flex: 1, + flexDirection: "column", + fontFamily: + "'Monaco', 'Consolas', 'SFMono-Regular', 'Liberation Mono', monospace", + minHeight: 0, + overflow: "hidden", +} as const; + +export const workbenchConsoleScrollStyle = { + flex: 1, + minHeight: 0, + overflowX: "hidden", + overflowY: "auto", + padding: 12, +} as const; + +export const workbenchMessageListStyle = { + display: "flex", + flexDirection: "column", + gap: 10, +} as const; + +export const workbenchEventHeaderStyle = { + borderBottom: "1px solid var(--ant-color-border-secondary)", + color: "var(--ant-color-text-secondary)", + display: "grid", + fontSize: 12, + gap: 12, + gridTemplateColumns: "220px 120px minmax(0, 1fr)", + padding: "12px 12px 8px", +} as const; + +export const workbenchEventRowStyle = { + borderBottom: "1px solid rgba(5, 5, 5, 0.06)", + display: "grid", + gap: 12, + gridTemplateColumns: "220px 120px minmax(0, 1fr)", + padding: "10px 12px", +} as const; + +export const recentRunColumns: ProColumns[] = [ + { + title: "Workflow", + dataIndex: "workflowName", + ellipsis: true, + }, + { + title: "Status", + dataIndex: "statusValue", + width: 120, + valueType: "status" as any, + valueEnum: runStatusValueEnum, + }, + { + title: "Recorded", + dataIndex: "recordedAt", + width: 220, + valueType: "dateTime", + render: (_, record) => formatDateTime(record.recordedAt), + }, + { + title: "RunId", + dataIndex: "runId", + width: 180, + render: (_, record) => record.runId || "n/a", + }, + { + title: "Preview", + dataIndex: "lastMessagePreview", + ellipsis: true, + render: (_, record) => + record.lastMessagePreview || record.prompt || "No preview recorded.", + }, + { + title: "Actions", + valueType: "option", + width: 160, + render: (_, record) => [ + + + {record.actorId ? ( + + ) : null} + , + ], + }, +]; + +export function trimOptional(value?: string | null): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +export function formatElapsedDuration(totalMilliseconds: number): string { + const totalSeconds = Math.max(0, Math.floor(totalMilliseconds / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return [hours, minutes, seconds] + .map((value) => value.toString().padStart(2, "0")) + .join(":"); + } + + return [minutes, seconds] + .map((value) => value.toString().padStart(2, "0")) + .join(":"); +} + +export function clampComposerWidth( + requestedWidth: number, + containerWidth: number +): number { + const maxWidth = Math.max( + composerRailMinWidth, + Math.min(composerRailMaxWidth, containerWidth - monitorWorkbenchMinWidth) + ); + + return Math.min(Math.max(requestedWidth, composerRailMinWidth), maxWidth); +} + +export function readInitialRunFormValues( + preferredWorkflow: string +): RunFormValues { + if (typeof window === "undefined") { + return { + prompt: "", + workflow: preferredWorkflow, + actorId: undefined, + transport: "sse", + }; + } + + const params = new URLSearchParams(window.location.search); + return { + prompt: params.get("prompt") ?? "", + workflow: trimOptional(params.get("workflow")) ?? preferredWorkflow, + actorId: trimOptional(params.get("actorId")), + transport: trimOptional(params.get("transport")) === "ws" ? "ws" : "sse", + }; +} diff --git a/apps/aevatar-console-web/src/pages/scopes/components/ScopeQueryCard.tsx b/apps/aevatar-console-web/src/pages/scopes/components/ScopeQueryCard.tsx new file mode 100644 index 00000000..38088f15 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/scopes/components/ScopeQueryCard.tsx @@ -0,0 +1,83 @@ +import { ProCard } from '@ant-design/pro-components'; +import { Button, Input, Space, Typography } from 'antd'; +import React from 'react'; +import { moduleCardProps } from '@/shared/ui/proComponents'; +import type { ScopeQueryDraft } from './scopeQuery'; + +type ScopeQueryCardProps = { + draft: ScopeQueryDraft; + onChange: (draft: ScopeQueryDraft) => void; + onLoad: () => void; + onReset?: () => void; + loadLabel?: string; + resolvedScopeId?: string | null; + resolvedScopeSource?: string | null; + onUseResolvedScope?: () => void; +}; + +const ScopeQueryCard: React.FC = ({ + draft, + onChange, + onLoad, + onReset, + loadLabel = 'Load scope', + resolvedScopeId, + resolvedScopeSource, + onUseResolvedScope, +}) => { + const normalizedResolvedScopeId = resolvedScopeId?.trim() ?? ''; + const normalizedResolvedScopeSource = resolvedScopeSource?.trim() ?? ''; + const canUseResolvedScope = + normalizedResolvedScopeId.length > 0 && + draft.scopeId.trim() !== normalizedResolvedScopeId && + onUseResolvedScope; + + return ( + + + + onChange({ + scopeId: event.target.value, + }) + } + onPressEnter={onLoad} + /> + + {onReset ? : null} + +
+ {normalizedResolvedScopeId ? ( + <> + Current scope + + {normalizedResolvedScopeId} + + {normalizedResolvedScopeSource ? ( + + Resolved from {normalizedResolvedScopeSource} + + ) : null} + {canUseResolvedScope ? ( + + ) : null} + + ) : ( + + No scope was resolved from the current session. Enter a scopeId manually. + + )} +
+
+ ); +}; + +export default ScopeQueryCard; diff --git a/apps/aevatar-console-web/src/pages/scopes/components/renderMultilineText.tsx b/apps/aevatar-console-web/src/pages/scopes/components/renderMultilineText.tsx new file mode 100644 index 00000000..c185536c --- /dev/null +++ b/apps/aevatar-console-web/src/pages/scopes/components/renderMultilineText.tsx @@ -0,0 +1,24 @@ +import { Typography } from 'antd'; +import React from 'react'; + +export function renderMultilineText(value: string | null | undefined) { + if (!value) { + return ( + No source attached. + ); + } + + return ( + + {value} + + ); +} diff --git a/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.test.ts b/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.test.ts new file mode 100644 index 00000000..6bde32f6 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.test.ts @@ -0,0 +1,79 @@ +import type { + StudioAppContext, + StudioAuthSession, +} from "@/shared/studio/models"; +import { resolveStudioScopeContext } from "./resolvedScope"; + +describe("resolvedScope", () => { + it("prefers the authenticated session scope", () => { + const authSession: StudioAuthSession = { + enabled: true, + authenticated: true, + scopeId: "scope-auth", + scopeSource: "claim:scope_id", + }; + const appContext: StudioAppContext = { + mode: "embedded", + scopeId: "scope-app", + scopeResolved: true, + scopeSource: "config:Cli:App:ScopeId", + workflowStorageMode: "scope", + features: { + publishedWorkflows: true, + scripts: true, + }, + }; + + expect(resolveStudioScopeContext(authSession, appContext)).toEqual({ + scopeId: "scope-auth", + scopeSource: "claim:scope_id", + }); + }); + + it("falls back to the app context scope when auth session has none", () => { + const authSession: StudioAuthSession = { + enabled: true, + authenticated: true, + scopeId: " ", + scopeSource: "claim:scope_id", + }; + const appContext: StudioAppContext = { + mode: "embedded", + scopeId: "scope-app", + scopeResolved: true, + scopeSource: "config:Cli:App:ScopeId", + workflowStorageMode: "scope", + features: { + publishedWorkflows: true, + scripts: true, + }, + }; + + expect(resolveStudioScopeContext(authSession, appContext)).toEqual({ + scopeId: "scope-app", + scopeSource: "config:Cli:App:ScopeId", + }); + }); + + it("returns null when neither source resolves a scope", () => { + const authSession: StudioAuthSession = { + enabled: true, + authenticated: false, + scopeId: null, + scopeSource: null, + }; + const appContext: StudioAppContext = { + mode: "embedded", + scopeId: null, + scopeResolved: false, + scopeSource: "", + workflowStorageMode: "workspace", + features: { + publishedWorkflows: true, + scripts: true, + }, + }; + + expect(resolveStudioScopeContext(authSession, appContext)).toBeNull(); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.ts b/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.ts new file mode 100644 index 00000000..54fef028 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.ts @@ -0,0 +1,38 @@ +import type { + StudioAppContext, + StudioAuthSession, +} from "@/shared/studio/models"; + +export type ResolvedScopeContext = { + scopeId: string; + scopeSource: string; +}; + +function trimOptional(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +export function resolveStudioScopeContext( + authSession?: StudioAuthSession | null, + appContext?: StudioAppContext | null +): ResolvedScopeContext | null { + const authScopeId = trimOptional(authSession?.scopeId); + if (authScopeId) { + return { + scopeId: authScopeId, + scopeSource: trimOptional(authSession?.scopeSource), + }; + } + + const appScopeId = appContext?.scopeResolved + ? trimOptional(appContext.scopeId) + : ""; + if (appScopeId) { + return { + scopeId: appScopeId, + scopeSource: trimOptional(appContext?.scopeSource), + }; + } + + return null; +} diff --git a/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.test.ts b/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.test.ts new file mode 100644 index 00000000..23902a28 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.test.ts @@ -0,0 +1,33 @@ +import { + buildScopeHref, + normalizeScopeDraft, + readScopeQueryDraft, +} from './scopeQuery'; + +describe('scopeQuery', () => { + it('reads scopeId from the query string', () => { + expect(readScopeQueryDraft('?scopeId=scope-alpha')).toEqual({ + scopeId: 'scope-alpha', + }); + }); + + it('normalizes whitespace around the scopeId', () => { + expect( + normalizeScopeDraft({ + scopeId: ' scope-alpha ', + }), + ).toEqual({ + scopeId: 'scope-alpha', + }); + }); + + it('builds scope routes with preserved scope and selection context', () => { + expect( + buildScopeHref( + '/scopes/workflows', + { scopeId: 'scope-alpha' }, + { workflowId: 'wf-1' }, + ), + ).toBe('/scopes/workflows?scopeId=scope-alpha&workflowId=wf-1'); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.ts b/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.ts new file mode 100644 index 00000000..62c785f0 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.ts @@ -0,0 +1,51 @@ +export type ScopeQueryDraft = { + scopeId: string; +}; + +function readString(value: string | null): string { + return value?.trim() ?? ''; +} + +export function normalizeScopeDraft(draft: ScopeQueryDraft): ScopeQueryDraft { + return { + scopeId: draft.scopeId.trim(), + }; +} + +export function readScopeQueryDraft( + search = typeof window === 'undefined' ? '' : window.location.search, +): ScopeQueryDraft { + const params = new URLSearchParams(search); + return { + scopeId: readString(params.get('scopeId')), + }; +} + +function buildScopeParams( + draft: ScopeQueryDraft, + extras?: Record, +): URLSearchParams { + const params = new URLSearchParams(); + + if (draft.scopeId.trim()) { + params.set('scopeId', draft.scopeId.trim()); + } + + for (const [key, value] of Object.entries(extras ?? {})) { + const normalized = value?.trim(); + if (normalized) { + params.set(key, normalized); + } + } + + return params; +} + +export function buildScopeHref( + path: string, + draft: ScopeQueryDraft, + extras?: Record, +): string { + const suffix = buildScopeParams(draft, extras).toString(); + return suffix ? `${path}?${suffix}` : path; +} diff --git a/apps/aevatar-console-web/src/pages/scopes/scripts.tsx b/apps/aevatar-console-web/src/pages/scopes/scripts.tsx new file mode 100644 index 00000000..3d0a08de --- /dev/null +++ b/apps/aevatar-console-web/src/pages/scopes/scripts.tsx @@ -0,0 +1,318 @@ +import type { + ProColumns, + ProDescriptionsItemProps, +} from "@ant-design/pro-components"; +import { + PageContainer, + ProCard, + ProDescriptions, + ProTable, +} from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import { Alert, Button, Col, Drawer, Row, Space, Typography } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { scopesApi } from "@/shared/api/scopesApi"; +import { studioApi } from "@/shared/studio/api"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import type { + ScopeScriptCatalog, + ScopeScriptDetail, + ScopeScriptSummary, +} from "@/shared/models/scopes"; +import { + compactTableCardProps, + moduleCardProps, +} from "@/shared/ui/proComponents"; +import ScopeQueryCard from "./components/ScopeQueryCard"; +import { resolveStudioScopeContext } from "./components/resolvedScope"; +import { renderMultilineText } from "./components/renderMultilineText"; +import { + buildScopeHref, + normalizeScopeDraft, + readScopeQueryDraft, + type ScopeQueryDraft, +} from "./components/scopeQuery"; + +const scriptDetailColumns: ProDescriptionsItemProps[] = [ + { + title: "Revision", + render: (_, record) => record.script?.activeRevision || "n/a", + }, + { + title: "Definition actor", + render: (_, record) => ( + + {record.script?.definitionActorId || "n/a"} + + ), + }, + { + title: "Catalog actor", + render: (_, record) => ( + + {record.script?.catalogActorId || "n/a"} + + ), + }, +]; + +const initialDraft = readScopeQueryDraft(); +const initialScriptId = + typeof window === "undefined" + ? "" + : new URLSearchParams(window.location.search).get("scriptId")?.trim() ?? ""; + +const ScopeScriptsPage: React.FC = () => { + const [draft, setDraft] = useState(initialDraft); + const [activeDraft, setActiveDraft] = useState(initialDraft); + const [selectedScriptId, setSelectedScriptId] = useState(initialScriptId); + const authSessionQuery = useQuery({ + queryKey: ["scopes", "auth-session"], + queryFn: () => studioApi.getAuthSession(), + retry: false, + }); + const studioHostAccessResolved = + !authSessionQuery.isLoading && !authSessionQuery.isError; + const studioHostAuthenticated = + authSessionQuery.data?.enabled === false || + Boolean(authSessionQuery.data?.authenticated); + const appContextQuery = useQuery({ + queryKey: ["scopes", "app-context"], + enabled: studioHostAccessResolved && studioHostAuthenticated, + queryFn: () => studioApi.getAppContext(), + retry: false, + }); + const resolvedScope = useMemo( + () => + resolveStudioScopeContext(authSessionQuery.data, appContextQuery.data), + [appContextQuery.data, authSessionQuery.data] + ); + + useEffect(() => { + history.replace( + buildScopeHref("/scopes/scripts", activeDraft, { + scriptId: selectedScriptId, + }) + ); + }, [activeDraft, selectedScriptId]); + + useEffect(() => { + if (!resolvedScope?.scopeId) { + return; + } + + setDraft((currentDraft) => + currentDraft.scopeId.trim() + ? currentDraft + : { scopeId: resolvedScope.scopeId } + ); + setActiveDraft((currentDraft) => + currentDraft.scopeId.trim() + ? currentDraft + : { scopeId: resolvedScope.scopeId } + ); + }, [resolvedScope?.scopeId]); + + const scriptsQuery = useQuery({ + queryKey: ["scopes", "scripts", activeDraft.scopeId], + enabled: activeDraft.scopeId.trim().length > 0, + queryFn: () => scopesApi.listScripts(activeDraft.scopeId), + }); + const scriptDetailQuery = useQuery({ + queryKey: [ + "scopes", + "script-detail", + activeDraft.scopeId, + selectedScriptId, + ], + enabled: + activeDraft.scopeId.trim().length > 0 && + selectedScriptId.trim().length > 0, + queryFn: () => + scopesApi.getScriptDetail(activeDraft.scopeId, selectedScriptId), + }); + const scriptCatalogQuery = useQuery({ + queryKey: [ + "scopes", + "script-catalog", + activeDraft.scopeId, + selectedScriptId, + ], + enabled: + activeDraft.scopeId.trim().length > 0 && + selectedScriptId.trim().length > 0, + queryFn: () => + scopesApi.getScriptCatalog(activeDraft.scopeId, selectedScriptId), + }); + + const scriptColumns = useMemo[]>( + () => [ + { + title: "Script", + dataIndex: "scriptId", + }, + { + title: "Revision", + dataIndex: "activeRevision", + }, + { + title: "Source hash", + dataIndex: "activeSourceHash", + render: (_, record) => ( + {record.activeSourceHash} + ), + }, + { + title: "Definition actor", + dataIndex: "definitionActorId", + render: (_, record) => ( + {record.definitionActorId} + ), + }, + { + title: "Updated", + dataIndex: "updatedAt", + render: (_, record) => formatDateTime(record.updatedAt), + }, + { + title: "Action", + valueType: "option", + render: (_, record) => [ + , + ], + }, + ], + [] + ); + + return ( + history.push(buildScopeHref("/scopes", activeDraft))} + > + + + { + if (!resolvedScope?.scopeId) { + return; + } + + const nextDraft = normalizeScopeDraft({ + scopeId: resolvedScope.scopeId, + }); + setDraft(nextDraft); + setActiveDraft(nextDraft); + setSelectedScriptId(""); + }} + onLoad={() => { + const nextDraft = normalizeScopeDraft(draft); + setDraft(nextDraft); + setActiveDraft(nextDraft); + setSelectedScriptId(""); + }} + onReset={() => { + const nextDraft = normalizeScopeDraft({ + scopeId: resolvedScope?.scopeId ?? "", + }); + setDraft(nextDraft); + setActiveDraft(nextDraft); + setSelectedScriptId(""); + }} + /> + + + + {activeDraft.scopeId.trim() ? ( + + columns={scriptColumns} + dataSource={scriptsQuery.data ?? []} + loading={scriptsQuery.isLoading} + rowKey="scriptId" + search={false} + pagination={{ pageSize: 10 }} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ) : ( + + )} + + + + 0} + title={selectedScriptId ? `Script ${selectedScriptId}` : "Script"} + size={760} + onClose={() => setSelectedScriptId("")} + > + {scriptDetailQuery.data ? ( + + + column={1} + dataSource={scriptDetailQuery.data} + columns={scriptDetailColumns} + /> + + {scriptCatalogQuery.data ? ( + + ) : ( + + Catalog snapshot unavailable. + + )} + + + {renderMultilineText(scriptDetailQuery.data.source?.sourceText)} + + + ) : ( + + )} + + + ); +}; + +const ScopeScriptCatalogSummary: React.FC<{ catalog: ScopeScriptCatalog }> = ({ + catalog, +}) => ( + + + Active revision: {catalog.activeRevision || "n/a"} + + + Previous revision: {catalog.previousRevision || "n/a"} + + + History: {catalog.revisionHistory.join(", ") || "n/a"} + + + Last proposal: {catalog.lastProposalId || "n/a"} + + +); + +export default ScopeScriptsPage; diff --git a/apps/aevatar-console-web/src/pages/scopes/workflows.tsx b/apps/aevatar-console-web/src/pages/scopes/workflows.tsx new file mode 100644 index 00000000..4bfe0da2 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/scopes/workflows.tsx @@ -0,0 +1,297 @@ +import type { + ProColumns, + ProDescriptionsItemProps, +} from "@ant-design/pro-components"; +import { + PageContainer, + ProCard, + ProDescriptions, + ProTable, +} from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import { Alert, Button, Col, Drawer, Row, Space, Typography } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { scopesApi } from "@/shared/api/scopesApi"; +import { studioApi } from "@/shared/studio/api"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import type { + ScopeWorkflowDetail, + ScopeWorkflowSummary, +} from "@/shared/models/scopes"; +import { + compactTableCardProps, + moduleCardProps, +} from "@/shared/ui/proComponents"; +import ScopeQueryCard from "./components/ScopeQueryCard"; +import { resolveStudioScopeContext } from "./components/resolvedScope"; +import { renderMultilineText } from "./components/renderMultilineText"; +import { + buildScopeHref, + normalizeScopeDraft, + readScopeQueryDraft, + type ScopeQueryDraft, +} from "./components/scopeQuery"; + +const workflowDetailColumns: ProDescriptionsItemProps[] = [ + { + title: "Display name", + dataIndex: ["workflow", "displayName"], + }, + { + title: "Service key", + render: (_, record) => ( + + {record.workflow?.serviceKey || "n/a"} + + ), + }, + { + title: "Definition actor", + render: (_, record) => ( + + {record.source?.definitionActorId || "n/a"} + + ), + }, +]; + +const initialDraft = readScopeQueryDraft(); +const initialWorkflowId = + typeof window === "undefined" + ? "" + : new URLSearchParams(window.location.search).get("workflowId")?.trim() ?? + ""; + +const ScopeWorkflowsPage: React.FC = () => { + const [draft, setDraft] = useState(initialDraft); + const [activeDraft, setActiveDraft] = useState(initialDraft); + const [selectedWorkflowId, setSelectedWorkflowId] = + useState(initialWorkflowId); + const authSessionQuery = useQuery({ + queryKey: ["scopes", "auth-session"], + queryFn: () => studioApi.getAuthSession(), + retry: false, + }); + const studioHostAccessResolved = + !authSessionQuery.isLoading && !authSessionQuery.isError; + const studioHostAuthenticated = + authSessionQuery.data?.enabled === false || + Boolean(authSessionQuery.data?.authenticated); + const appContextQuery = useQuery({ + queryKey: ["scopes", "app-context"], + enabled: studioHostAccessResolved && studioHostAuthenticated, + queryFn: () => studioApi.getAppContext(), + retry: false, + }); + const resolvedScope = useMemo( + () => + resolveStudioScopeContext(authSessionQuery.data, appContextQuery.data), + [appContextQuery.data, authSessionQuery.data] + ); + + useEffect(() => { + history.replace( + buildScopeHref("/scopes/workflows", activeDraft, { + workflowId: selectedWorkflowId, + }) + ); + }, [activeDraft, selectedWorkflowId]); + + useEffect(() => { + if (!resolvedScope?.scopeId) { + return; + } + + setDraft((currentDraft) => + currentDraft.scopeId.trim() + ? currentDraft + : { scopeId: resolvedScope.scopeId } + ); + setActiveDraft((currentDraft) => + currentDraft.scopeId.trim() + ? currentDraft + : { scopeId: resolvedScope.scopeId } + ); + }, [resolvedScope?.scopeId]); + + const workflowsQuery = useQuery({ + queryKey: ["scopes", "workflows", activeDraft.scopeId], + enabled: activeDraft.scopeId.trim().length > 0, + queryFn: () => scopesApi.listWorkflows(activeDraft.scopeId), + }); + const workflowDetailQuery = useQuery({ + queryKey: [ + "scopes", + "workflow-detail", + activeDraft.scopeId, + selectedWorkflowId, + ], + enabled: + activeDraft.scopeId.trim().length > 0 && + selectedWorkflowId.trim().length > 0, + queryFn: () => + scopesApi.getWorkflowDetail(activeDraft.scopeId, selectedWorkflowId), + }); + + const workflowColumns = useMemo[]>( + () => [ + { + title: "Workflow", + dataIndex: "workflowId", + render: (_, record) => ( + + + {record.displayName || record.workflowId} + + + {record.workflowId} + + + ), + }, + { + title: "Workflow name", + dataIndex: "workflowName", + }, + { + title: "Actor", + dataIndex: "actorId", + render: (_, record) => ( + {record.actorId} + ), + }, + { + title: "Revision", + dataIndex: "activeRevisionId", + }, + { + title: "Deployment", + dataIndex: "deploymentStatus", + render: (_, record) => + `${record.deploymentStatus || "unknown"}${ + record.deploymentId ? ` · ${record.deploymentId}` : "" + }`, + }, + { + title: "Updated", + dataIndex: "updatedAt", + render: (_, record) => formatDateTime(record.updatedAt), + }, + { + title: "Action", + valueType: "option", + render: (_, record) => [ + , + ], + }, + ], + [] + ); + + return ( + history.push(buildScopeHref("/scopes", activeDraft))} + > + + + { + if (!resolvedScope?.scopeId) { + return; + } + + const nextDraft = normalizeScopeDraft({ + scopeId: resolvedScope.scopeId, + }); + setDraft(nextDraft); + setActiveDraft(nextDraft); + setSelectedWorkflowId(""); + }} + onLoad={() => { + const nextDraft = normalizeScopeDraft(draft); + setDraft(nextDraft); + setActiveDraft(nextDraft); + setSelectedWorkflowId(""); + }} + onReset={() => { + const nextDraft = normalizeScopeDraft({ + scopeId: resolvedScope?.scopeId ?? "", + }); + setDraft(nextDraft); + setActiveDraft(nextDraft); + setSelectedWorkflowId(""); + }} + /> + + + + {activeDraft.scopeId.trim() ? ( + + columns={workflowColumns} + dataSource={workflowsQuery.data ?? []} + loading={workflowsQuery.isLoading} + rowKey="workflowId" + search={false} + pagination={{ pageSize: 10 }} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ) : ( + + )} + + + + 0} + title={ + selectedWorkflowId ? `Workflow ${selectedWorkflowId}` : "Workflow" + } + size={760} + onClose={() => setSelectedWorkflowId("")} + > + {workflowDetailQuery.data ? ( + + + column={1} + dataSource={workflowDetailQuery.data} + columns={workflowDetailColumns} + /> + + {renderMultilineText( + workflowDetailQuery.data.source?.workflowYaml + )} + + + ) : ( + + )} + + + ); +}; + +export default ScopeWorkflowsPage; diff --git a/apps/aevatar-console-web/src/pages/services/components/ServiceQueryCard.tsx b/apps/aevatar-console-web/src/pages/services/components/ServiceQueryCard.tsx new file mode 100644 index 00000000..5589e4fd --- /dev/null +++ b/apps/aevatar-console-web/src/pages/services/components/ServiceQueryCard.tsx @@ -0,0 +1,78 @@ +import { ProCard } from '@ant-design/pro-components'; +import { Button, Input, InputNumber, Space } from 'antd'; +import React from 'react'; +import { moduleCardProps } from '@/shared/ui/proComponents'; +import type { ServiceQueryDraft } from './serviceQuery'; + +type ServiceQueryCardProps = { + draft: ServiceQueryDraft; + onChange: (draft: ServiceQueryDraft) => void; + onLoad: () => void; + onReset?: () => void; + loadLabel?: string; +}; + +const ServiceQueryCard: React.FC = ({ + draft, + onChange, + onLoad, + onReset, + loadLabel = 'Load services', +}) => { + return ( + + + + onChange({ + ...draft, + tenantId: event.target.value, + }) + } + /> + + onChange({ + ...draft, + appId: event.target.value, + }) + } + /> + + onChange({ + ...draft, + namespace: event.target.value, + }) + } + /> + + onChange({ + ...draft, + take: Number(value) || 200, + }) + } + /> + + {onReset ? : null} + + + ); +}; + +export default ServiceQueryCard; diff --git a/apps/aevatar-console-web/src/pages/services/components/serviceQuery.test.ts b/apps/aevatar-console-web/src/pages/services/components/serviceQuery.test.ts new file mode 100644 index 00000000..3e42f33c --- /dev/null +++ b/apps/aevatar-console-web/src/pages/services/components/serviceQuery.test.ts @@ -0,0 +1,58 @@ +import { + buildServiceDetailHref, + buildServicesHref, + readServiceIdFromPathname, + readServiceQueryDraft, + trimServiceQuery, +} from './serviceQuery'; + +describe('serviceQuery', () => { + it('reads service catalog filters from the query string', () => { + expect( + readServiceQueryDraft('?tenantId=t1&appId=a1&namespace=n1&take=25'), + ).toEqual({ + tenantId: 't1', + appId: 'a1', + namespace: 'n1', + take: 25, + }); + }); + + it('trims catalog filter values before they become an API query', () => { + expect( + trimServiceQuery({ + tenantId: ' t1 ', + appId: ' a1 ', + namespace: ' n1 ', + take: 200, + }), + ).toEqual({ + tenantId: 't1', + appId: 'a1', + namespace: 'n1', + take: 200, + }); + }); + + it('builds list and detail URLs that preserve the service identity query', () => { + const query = { + tenantId: 't1', + appId: 'a1', + namespace: 'n1', + take: 20, + }; + + expect(buildServicesHref(query)).toBe( + '/services?tenantId=t1&appId=a1&namespace=n1&take=20', + ); + expect(buildServiceDetailHref('service.alpha', query)).toBe( + '/services/service.alpha?tenantId=t1&appId=a1&namespace=n1&take=20', + ); + }); + + it('reads the concrete service identifier from a detail route pathname', () => { + expect(readServiceIdFromPathname('/services/service.alpha')).toBe( + 'service.alpha', + ); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/services/components/serviceQuery.ts b/apps/aevatar-console-web/src/pages/services/components/serviceQuery.ts new file mode 100644 index 00000000..86554357 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/services/components/serviceQuery.ts @@ -0,0 +1,89 @@ +import type { ServiceIdentityQuery } from '@/shared/models/services'; + +export type ServiceQueryDraft = { + tenantId: string; + appId: string; + namespace: string; + take: number; +}; + +const defaultTake = 200; + +function readString(value: string | null): string { + return value?.trim() ?? ''; +} + +export function trimServiceQuery( + draft: ServiceQueryDraft, +): ServiceIdentityQuery { + return { + tenantId: draft.tenantId.trim(), + appId: draft.appId.trim(), + namespace: draft.namespace.trim(), + take: draft.take, + }; +} + +export function readServiceQueryDraft( + search = typeof window === 'undefined' ? '' : window.location.search, +): ServiceQueryDraft { + const params = new URLSearchParams(search); + const parsedTake = Number(params.get('take')); + + return { + tenantId: readString(params.get('tenantId')), + appId: readString(params.get('appId')), + namespace: readString(params.get('namespace')), + take: + Number.isFinite(parsedTake) && parsedTake > 0 ? parsedTake : defaultTake, + }; +} + +function buildServiceSearchParams( + query: ServiceIdentityQuery, +): URLSearchParams { + const params = new URLSearchParams(); + + if (query.tenantId?.trim()) { + params.set('tenantId', query.tenantId.trim()); + } + if (query.appId?.trim()) { + params.set('appId', query.appId.trim()); + } + if (query.namespace?.trim()) { + params.set('namespace', query.namespace.trim()); + } + if (query.take && query.take > 0) { + params.set('take', String(query.take)); + } + + return params; +} + +export function buildServicesHref(query: ServiceIdentityQuery): string { + const params = buildServiceSearchParams(query); + const suffix = params.toString(); + return suffix ? `/services?${suffix}` : '/services'; +} + +export function buildServiceDetailHref( + serviceId: string, + query: ServiceIdentityQuery, +): string { + const normalizedServiceId = serviceId.trim(); + const params = buildServiceSearchParams(query); + const path = `/services/${encodeURIComponent(normalizedServiceId)}`; + const suffix = params.toString(); + return suffix ? `${path}?${suffix}` : path; +} + +export function readServiceIdFromPathname( + pathname = typeof window === 'undefined' ? '' : window.location.pathname, +): string { + const segments = pathname + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean); + const serviceId = segments.at(-1) ?? ''; + return serviceId ? decodeURIComponent(serviceId) : ''; +} diff --git a/apps/aevatar-console-web/src/pages/services/detail.tsx b/apps/aevatar-console-web/src/pages/services/detail.tsx new file mode 100644 index 00000000..ae6e7f9c --- /dev/null +++ b/apps/aevatar-console-web/src/pages/services/detail.tsx @@ -0,0 +1,486 @@ +import type { + ProColumns, + ProDescriptionsItemProps, +} from "@ant-design/pro-components"; +import { + PageContainer, + ProCard, + ProDescriptions, + ProTable, +} from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import { Alert, Button, Col, Row, Space, Tabs, Typography } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { servicesApi } from "@/shared/api/servicesApi"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import type { + ServiceDeploymentSnapshot, + ServiceEndpointSnapshot, + ServiceIdentityQuery, + ServiceRevisionSnapshot, + ServiceServingTargetSnapshot, + ServiceTrafficEndpointSnapshot, +} from "@/shared/models/services"; +import { + compactTableCardProps, + moduleCardProps, +} from "@/shared/ui/proComponents"; +import ServiceQueryCard from "./components/ServiceQueryCard"; +import { + buildServiceDetailHref, + buildServicesHref, + readServiceIdFromPathname, + readServiceQueryDraft, + trimServiceQuery, + type ServiceQueryDraft, +} from "./components/serviceQuery"; + +type ServiceSummaryRecord = { + serviceKey: string; + displayName: string; + endpointCount: number; + policyCount: number; + deploymentStatus: string; + updatedAt: string; +}; + +const summaryColumns: ProDescriptionsItemProps[] = [ + { + title: "Service key", + dataIndex: "serviceKey", + render: (_, record) => ( + {record.serviceKey} + ), + }, + { + title: "Display name", + dataIndex: "displayName", + }, + { + title: "Endpoints", + dataIndex: "endpointCount", + valueType: "digit", + }, + { + title: "Policies", + dataIndex: "policyCount", + valueType: "digit", + }, + { + title: "Deployment status", + dataIndex: "deploymentStatus", + }, + { + title: "Updated", + dataIndex: "updatedAt", + render: (_, record) => formatDateTime(record.updatedAt), + }, +]; + +const initialDraft = readServiceQueryDraft(); +const initialServiceId = readServiceIdFromPathname(); + +const ServiceDetailPage: React.FC = () => { + const [draft, setDraft] = useState(initialDraft); + const [query, setQuery] = useState( + trimServiceQuery(initialDraft) + ); + const [serviceId, setServiceId] = useState(initialServiceId); + + useEffect(() => { + if (serviceId.trim()) { + history.replace(buildServiceDetailHref(serviceId, query)); + } + }, [query, serviceId]); + + const serviceDetailQuery = useQuery({ + queryKey: ["services", "detail", query, serviceId], + enabled: serviceId.trim().length > 0, + queryFn: () => servicesApi.getService(serviceId, query), + }); + const revisionsQuery = useQuery({ + queryKey: ["services", "revisions", query, serviceId], + enabled: serviceId.trim().length > 0, + queryFn: () => servicesApi.getRevisions(serviceId, query), + }); + const deploymentsQuery = useQuery({ + queryKey: ["services", "deployments", query, serviceId], + enabled: serviceId.trim().length > 0, + queryFn: () => servicesApi.getDeployments(serviceId, query), + }); + const servingQuery = useQuery({ + queryKey: ["services", "serving", query, serviceId], + enabled: serviceId.trim().length > 0, + queryFn: () => servicesApi.getServingSet(serviceId, query), + }); + const rolloutQuery = useQuery({ + queryKey: ["services", "rollout", query, serviceId], + enabled: serviceId.trim().length > 0, + queryFn: () => servicesApi.getRollout(serviceId, query), + }); + const trafficQuery = useQuery({ + queryKey: ["services", "traffic", query, serviceId], + enabled: serviceId.trim().length > 0, + queryFn: () => servicesApi.getTraffic(serviceId, query), + }); + + const endpointColumns = useMemo[]>( + () => [ + { + title: "Endpoint", + dataIndex: "endpointId", + }, + { + title: "Display name", + dataIndex: "displayName", + }, + { + title: "Kind", + dataIndex: "kind", + }, + { + title: "Request type", + dataIndex: "requestTypeUrl", + }, + { + title: "Response type", + dataIndex: "responseTypeUrl", + }, + ], + [] + ); + + const revisionColumns = useMemo[]>( + () => [ + { + title: "Revision", + dataIndex: "revisionId", + }, + { + title: "Implementation", + dataIndex: "implementationKind", + }, + { + title: "Status", + dataIndex: "status", + }, + { + title: "Artifact hash", + dataIndex: "artifactHash", + render: (_, record) => ( + {record.artifactHash} + ), + }, + { + title: "Published", + render: (_, record) => formatDateTime(record.publishedAt), + }, + ], + [] + ); + + const deploymentColumns = useMemo[]>( + () => [ + { + title: "Deployment", + dataIndex: "deploymentId", + }, + { + title: "Revision", + dataIndex: "revisionId", + }, + { + title: "Status", + dataIndex: "status", + }, + { + title: "Primary actor", + dataIndex: "primaryActorId", + render: (_, record) => ( + {record.primaryActorId} + ), + }, + { + title: "Activated", + render: (_, record) => formatDateTime(record.activatedAt), + }, + ], + [] + ); + + const servingColumns = useMemo[]>( + () => [ + { + title: "Deployment", + dataIndex: "deploymentId", + }, + { + title: "Revision", + dataIndex: "revisionId", + }, + { + title: "Weight", + dataIndex: "allocationWeight", + }, + { + title: "State", + dataIndex: "servingState", + }, + { + title: "Enabled endpoints", + render: (_, record) => record.enabledEndpointIds.join(", ") || "all", + }, + ], + [] + ); + + const trafficColumns = useMemo[]>( + () => [ + { + title: "Endpoint", + dataIndex: "endpointId", + }, + { + title: "Targets", + render: (_, record) => + record.targets + .map( + (item) => + `${item.revisionId}:${item.allocationWeight}% (${item.servingState})` + ) + .join(" | "), + }, + ], + [] + ); + + const summaryRecord = useMemo(() => { + if (!serviceDetailQuery.data) { + return undefined; + } + + return { + serviceKey: serviceDetailQuery.data.serviceKey, + displayName: serviceDetailQuery.data.displayName, + endpointCount: serviceDetailQuery.data.endpoints.length, + policyCount: serviceDetailQuery.data.policyIds.length, + deploymentStatus: serviceDetailQuery.data.deploymentStatus, + updatedAt: serviceDetailQuery.data.updatedAt, + }; + }, [serviceDetailQuery.data]); + + return ( + history.push(buildServicesHref(query))} + > + Back to catalog + , + , + ]} + > + + + { + const nextQuery = trimServiceQuery(draft); + setQuery(nextQuery); + setServiceId(readServiceIdFromPathname()); + }} + /> + + + + {!serviceId ? ( + + ) : summaryRecord ? ( + + + + column={2} + dataSource={summaryRecord} + columns={summaryColumns} + /> + + + + columns={endpointColumns} + dataSource={serviceDetailQuery.data?.endpoints ?? []} + rowKey="endpointId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ), + }, + { + key: "revisions", + label: `Revisions (${ + revisionsQuery.data?.revisions.length ?? 0 + })`, + children: ( + + columns={revisionColumns} + dataSource={revisionsQuery.data?.revisions ?? []} + rowKey="revisionId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ), + }, + { + key: "deployments", + label: `Deployments (${ + deploymentsQuery.data?.deployments.length ?? 0 + })`, + children: ( + + columns={deploymentColumns} + dataSource={deploymentsQuery.data?.deployments ?? []} + rowKey="deploymentId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ), + }, + { + key: "serving", + label: `Serving (${ + servingQuery.data?.targets.length ?? 0 + })`, + children: ( + + columns={servingColumns} + dataSource={servingQuery.data?.targets ?? []} + rowKey="deploymentId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ), + }, + { + key: "rollout", + label: "Rollout", + children: rolloutQuery.data ? ( + + + + columns={servingColumns} + dataSource={rolloutQuery.data.stages.flatMap( + (stage) => stage.targets + )} + rowKey={(record) => + `${record.deploymentId}-${record.revisionId}` + } + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + + ) : ( + + ), + }, + { + key: "traffic", + label: `Traffic (${ + trafficQuery.data?.endpoints.length ?? 0 + })`, + children: ( + + columns={trafficColumns} + dataSource={trafficQuery.data?.endpoints ?? []} + rowKey="endpointId" + search={false} + pagination={false} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + ), + }, + ]} + /> + + ) : ( + + )} + + + + ); +}; + +export default ServiceDetailPage; diff --git a/apps/aevatar-console-web/src/pages/services/index.tsx b/apps/aevatar-console-web/src/pages/services/index.tsx new file mode 100644 index 00000000..41856091 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/services/index.tsx @@ -0,0 +1,273 @@ +import type { + ProColumns, + ProDescriptionsItemProps, +} from '@ant-design/pro-components'; +import { + PageContainer, + ProCard, + ProDescriptions, + ProTable, +} from '@ant-design/pro-components'; +import { useQuery } from '@tanstack/react-query'; +import { history } from '@umijs/max'; +import { Button, Col, Row, Space, Statistic, Typography } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import { servicesApi } from '@/shared/api/servicesApi'; +import { formatDateTime } from '@/shared/datetime/dateTime'; +import type { + ServiceCatalogSnapshot, + ServiceIdentityQuery, +} from '@/shared/models/services'; +import { + compactTableCardProps, + fillCardStyle, + moduleCardProps, + stretchColumnStyle, +} from '@/shared/ui/proComponents'; +import ServiceQueryCard from './components/ServiceQueryCard'; +import { + buildServiceDetailHref, + buildServicesHref, + readServiceQueryDraft, + trimServiceQuery, + type ServiceQueryDraft, +} from './components/serviceQuery'; + +type ServiceCatalogSummaryRecord = { + services: number; + activeDeployments: number; + endpoints: number; + policies: number; +}; + +const summaryColumns: ProDescriptionsItemProps[] = + [ + { + title: 'Services', + dataIndex: 'services', + valueType: 'digit', + }, + { + title: 'Active deployments', + dataIndex: 'activeDeployments', + valueType: 'digit', + }, + { + title: 'Endpoints', + dataIndex: 'endpoints', + valueType: 'digit', + }, + { + title: 'Policies', + dataIndex: 'policies', + valueType: 'digit', + }, + ]; + +const initialDraft = readServiceQueryDraft(); + +const ServicesPage: React.FC = () => { + const [draft, setDraft] = useState(initialDraft); + const [query, setQuery] = useState( + trimServiceQuery(initialDraft), + ); + + const servicesQuery = useQuery({ + queryKey: ['services', query], + queryFn: () => servicesApi.listServices(query), + }); + + useEffect(() => { + history.replace(buildServicesHref(query)); + }, [query]); + + const serviceColumns = useMemo[]>( + () => [ + { + title: 'Service', + dataIndex: 'serviceId', + render: (_, record) => ( + + + {record.displayName || record.serviceId} + + + {record.serviceId} + + + ), + }, + { + title: 'Namespace', + dataIndex: 'namespace', + }, + { + title: 'Endpoints', + render: (_, record) => record.endpoints.length, + }, + { + title: 'Policies', + render: (_, record) => record.policyIds.length, + }, + { + title: 'Serving revision', + render: (_, record) => + record.activeServingRevisionId || + record.defaultServingRevisionId || + 'n/a', + }, + { + title: 'Deployment', + render: (_, record) => + `${record.deploymentStatus || 'unknown'}${ + record.deploymentId ? ` · ${record.deploymentId}` : '' + }`, + }, + { + title: 'Updated', + dataIndex: 'updatedAt', + render: (_, record) => formatDateTime(record.updatedAt), + }, + { + title: 'Action', + valueType: 'option', + render: (_, record) => [ + , + , + ], + }, + ], + [query], + ); + + const summaryRecord = useMemo( + () => ({ + services: servicesQuery.data?.length ?? 0, + activeDeployments: (servicesQuery.data ?? []).filter( + (item) => item.deploymentId.trim().length > 0, + ).length, + endpoints: (servicesQuery.data ?? []).reduce( + (count, item) => count + item.endpoints.length, + 0, + ), + policies: (servicesQuery.data ?? []).reduce( + (count, item) => count + item.policyIds.length, + 0, + ), + }), + [servicesQuery.data], + ); + + return ( + + + + setQuery(trimServiceQuery(draft))} + onReset={() => { + const nextDraft = readServiceQueryDraft(''); + setDraft(nextDraft); + setQuery(trimServiceQuery(nextDraft)); + }} + /> + + + + + + + + + + + + + + + + + + + + + + column={2} + columns={summaryColumns} + dataSource={summaryRecord} + /> + + + + + + + + + + + + + + + columns={serviceColumns} + dataSource={servicesQuery.data ?? []} + loading={servicesQuery.isLoading} + rowKey="serviceKey" + search={false} + pagination={{ pageSize: 10 }} + cardProps={compactTableCardProps} + toolBarRender={false} + /> + + + + ); +}; + +export default ServicesPage; diff --git a/apps/aevatar-console-web/src/pages/settings/console.test.tsx b/apps/aevatar-console-web/src/pages/settings/console.test.tsx new file mode 100644 index 00000000..2d4aec93 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/settings/console.test.tsx @@ -0,0 +1,44 @@ +import { screen } from "@testing-library/react"; +import React from "react"; +import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; +import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; +import ConsoleSettingsPage from "./console"; + +jest.mock("@/shared/api/runtimeCatalogApi", () => ({ + runtimeCatalogApi: { + listWorkflowCatalog: jest.fn(async () => [ + { + name: "incident_triage", + description: "Incident triage", + category: "ops", + group: "starter", + groupLabel: "Starter", + sortOrder: 1, + source: "home", + sourceLabel: "Saved", + showInLibrary: true, + isPrimitiveExample: false, + requiresLlmProvider: true, + primitives: ["llm_call"], + }, + ]), + }, +})); + +describe("ConsoleSettingsPage", () => { + beforeEach(() => { + window.localStorage.clear(); + jest.clearAllMocks(); + }); + + it("renders console preference sections on the dedicated settings page", async () => { + renderWithQueryClient(React.createElement(ConsoleSettingsPage)); + + expect(await screen.findByText("Console preferences")).toBeTruthy(); + expect(screen.getByText("Workbench appearance")).toBeTruthy(); + expect(screen.getByText("Workflow defaults")).toBeTruthy(); + expect(screen.getByText("Observability URLs")).toBeTruthy(); + expect(screen.getByText("Runtime explorer defaults")).toBeTruthy(); + expect(runtimeCatalogApi.listWorkflowCatalog).toHaveBeenCalled(); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/settings/console.tsx b/apps/aevatar-console-web/src/pages/settings/console.tsx new file mode 100644 index 00000000..8d96f4a5 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/settings/console.tsx @@ -0,0 +1,493 @@ +import type { + ProDescriptionsItemProps, + ProFormInstance, +} from "@ant-design/pro-components"; +import { + PageContainer, + ProCard, + ProDescriptions, + ProForm, + ProFormDigit, + ProFormSelect, + ProFormText, + ProList, +} from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import { Button, Col, Empty, Row, Space, Tag, Typography, message } from "antd"; +import React, { useMemo, useRef, useState } from "react"; +import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; +import { + buildObservabilityTargets, + type ObservabilityTarget, +} from "@/shared/observability/observabilityLinks"; +import { + type ActorGraphDirection, + type ConsolePreferences, + loadConsolePreferences, + resetConsolePreferences, + saveConsolePreferences, + type StudioAppearanceTheme, + type StudioColorMode, +} from "@/shared/preferences/consolePreferences"; +import { buildWorkflowCatalogOptions } from "@/shared/workflows/catalogVisibility"; +import { + fillCardStyle, + moduleCardProps, + stretchColumnStyle, +} from "@/shared/ui/proComponents"; + +type ConsoleSettingsSummaryRecord = { + preferredWorkflow: string; + graphDirection: ActorGraphDirection; + studioAppearanceTheme: StudioAppearanceTheme; + studioColorMode: StudioColorMode; + observabilityTargetsConfigured: number; +}; + +const summaryColumns: ProDescriptionsItemProps[] = + [ + { + title: "Preferred workflow", + dataIndex: "preferredWorkflow", + render: (_, record) => ( + {record.preferredWorkflow} + ), + }, + { + title: "Graph direction", + dataIndex: "graphDirection", + }, + { + title: "Accent theme", + dataIndex: "studioAppearanceTheme", + }, + { + title: "Color mode", + dataIndex: "studioColorMode", + }, + { + title: "Configured targets", + dataIndex: "observabilityTargetsConfigured", + valueType: "digit", + }, + ]; + +const studioAppearanceOptions: Array<{ + label: string; + value: StudioAppearanceTheme; +}> = [ + { label: "Blue", value: "blue" }, + { label: "Coral", value: "coral" }, + { label: "Forest", value: "forest" }, +]; + +const studioColorModeOptions: Array<{ + label: string; + value: StudioColorMode; +}> = [ + { label: "Light", value: "light" }, + { label: "Dark", value: "dark" }, +]; + +const consoleUsageNotes = [ + { + id: "console-defaults", + text: "These settings are stored locally in the browser and apply to console navigation, runtime explorer defaults, and outbound observability links.", + }, + { + id: "console-observability", + text: "Grafana, Jaeger, and Loki URLs are not proxied. The console only builds outbound links and preserves current workflow context.", + }, +]; + +const ConsoleSettingsPage: React.FC = () => { + const formRef = useRef | undefined>( + undefined + ); + const [messageApi, messageContextHolder] = message.useMessage(); + const [preferences, setPreferences] = useState( + loadConsolePreferences() + ); + + const workflowCatalogQuery = useQuery({ + queryKey: ["settings-console", "workflow-catalog"], + queryFn: () => runtimeCatalogApi.listWorkflowCatalog(), + }); + + const workflowOptions = useMemo( + () => + buildWorkflowCatalogOptions( + workflowCatalogQuery.data ?? [], + preferences.preferredWorkflow + ), + [preferences.preferredWorkflow, workflowCatalogQuery.data] + ); + + const observabilityTargets = useMemo( + () => + buildObservabilityTargets(preferences, { + workflow: preferences.preferredWorkflow, + actorId: "", + commandId: "", + runId: "", + stepId: "", + }), + [preferences] + ); + + const summaryRecord = useMemo( + () => ({ + preferredWorkflow: preferences.preferredWorkflow, + graphDirection: preferences.actorGraphDirection, + studioAppearanceTheme: preferences.studioAppearanceTheme, + studioColorMode: preferences.studioColorMode, + observabilityTargetsConfigured: observabilityTargets.filter( + (target) => target.status === "configured" + ).length, + }), + [observabilityTargets, preferences] + ); + + const handleSavePreferences = async (values: ConsolePreferences) => { + const next = saveConsolePreferences(values); + setPreferences(next); + messageApi.success("Console preferences saved."); + return true; + }; + + const handleResetPreferences = () => { + const next = resetConsolePreferences(); + setPreferences(next); + formRef.current?.setFieldsValue(next); + messageApi.success("Console preferences reset to defaults."); + }; + + return ( + history.push("/overview")} + extra={[ + , + ]} + > + {messageContextHolder} + + + + + formRef={formRef} + layout="vertical" + initialValues={preferences} + onFinish={handleSavePreferences} + submitter={{ + render: (props) => ( + + + + + + ), + }} + > + + + + + + name="studioAppearanceTheme" + label="Accent theme" + options={studioAppearanceOptions} + rules={[ + { + required: true, + message: "Accent theme is required.", + }, + ]} + /> + + + + name="studioColorMode" + label="Color mode" + options={studioColorModeOptions} + rules={[ + { + required: true, + message: "Color mode is required.", + }, + ]} + /> + + + + These appearance preferences are stored locally and reused + by linked console workbench surfaces. + + + + + + + + Loading workflows... + + ) : ( + + ), + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + name="actorGraphDirection" + label="Actor graph direction" + options={[ + { label: "Both", value: "Both" }, + { label: "Outbound", value: "Outbound" }, + { label: "Inbound", value: "Inbound" }, + ]} + rules={[ + { + required: true, + message: "Graph direction is required.", + }, + ]} + /> + + + + + + + + + + + + + column={1} + dataSource={summaryRecord} + columns={summaryColumns} + /> + + + + rowKey="id" + search={false} + split + dataSource={observabilityTargets} + locale={{ + emptyText: ( + + ), + }} + metas={{ + title: { + dataIndex: "label", + render: (_, record) => ( + + {record.label} + + {record.status} + + + ), + }, + description: { + dataIndex: "description", + }, + subTitle: { + render: (_, record) => + record.homeUrl ? ( + {record.homeUrl} + ) : ( + No URL configured + ), + }, + actions: { + render: (_, record) => [ + , + , + ], + }, + }} + /> + + + + {consoleUsageNotes.map((item) => ( + {item.text} + ))} + + + + + + + ); +}; + +export default ConsoleSettingsPage; diff --git a/apps/aevatar-console-web/src/pages/settings/index.tsx b/apps/aevatar-console-web/src/pages/settings/index.tsx deleted file mode 100644 index 593ba4ad..00000000 --- a/apps/aevatar-console-web/src/pages/settings/index.tsx +++ /dev/null @@ -1,3636 +0,0 @@ -import type { - ProDescriptionsItemProps, - ProFormInstance, -} from '@ant-design/pro-components'; -import { - PageContainer, - ProCard, - ProDescriptions, - ProForm, - ProFormDigit, - ProFormSelect, - ProFormText, - ProList, -} from '@ant-design/pro-components'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { history } from '@umijs/max'; -import { - Alert, - Avatar, - Button, - Col, - Empty, - Grid, - Input, - message, - Row, - Select, - Space, - Tabs, - Tag, - Typography, -} from 'antd'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { configurationApi } from '@/shared/api/configurationApi'; -import { consoleApi } from '@/shared/api/consoleApi'; -import type { - ConfigurationEmbeddingsStatus, - ConfigurationLlmApiKeyStatus, - ConfigurationLlmProbeResult, - ConfigurationMcpServer, - ConfigurationPathStatus, - ConfigurationSecp256k1Status, - ConfigurationSecretValueStatus, - ConfigurationSkillsMpStatus, - ConfigurationWebSearchStatus, - ConfigurationWorkflowFile, -} from '@/shared/api/models'; -import { loadStoredAuthSession } from '@/shared/auth/session'; -import { formatDateTime } from '@/shared/datetime/dateTime'; -import { buildObservabilityTargets } from '@/shared/observability/observabilityLinks'; -import { - type ActorGraphDirection, - type ConsolePreferences, - loadConsolePreferences, - resetConsolePreferences, - saveConsolePreferences, - type StudioAppearanceTheme, - type StudioColorMode, -} from '@/shared/preferences/consolePreferences'; -import { buildWorkflowCatalogOptions } from '@/shared/workflows/catalogVisibility'; -import { - fillCardStyle, - moduleCardProps, - stretchColumnStyle, -} from '@/shared/ui/proComponents'; - -type SettingsSummaryRecord = { - preferredWorkflow: string; - graphDirection: ActorGraphDirection; - grafanaStatus: 'configured' | 'missing'; - configStatus: 'ready' | 'unavailable'; - configMode: string; - runtimeWorkflowFiles: number; - primitiveCount: number; - defaultProvider: string; - observabilityTargetsConfigured: number; -}; - -type SettingsHintItem = { - id: string; - text: string; -}; - -type SettingsObservabilityItem = { - id: string; - label: string; - description: string; - status: 'configured' | 'missing'; - homeUrl: string; - exploreUrl: string; -}; - -type SettingsProfileIdentityRecord = { - provider: string; - displayName: string; - email: string; - subject: string; - emailVerified: 'Verified' | 'Unverified' | 'Unknown'; -}; - -type SettingsProfileAccessRecord = { - roles: string[]; - groups: string[]; - permissions: string[]; - scope: string; - expiresAt: string; -}; - -type ConfigurationPathRecord = { - id: string; - label: string; - status: ConfigurationPathStatus; -}; - -type WorkflowDraftSource = 'home' | 'repo'; - -const grafanaValueEnum = { - configured: { text: 'Configured', status: 'Success' }, - missing: { text: 'Not configured', status: 'Default' }, -} as const; - -const configurationValueEnum = { - ready: { text: 'Ready', status: 'Success' }, - unavailable: { text: 'Unavailable', status: 'Error' }, -} as const; - -const settingsSummaryColumns: ProDescriptionsItemProps[] = - [ - { - title: 'Preferred workflow', - dataIndex: 'preferredWorkflow', - render: (_, record) => ( - {record.preferredWorkflow} - ), - }, - { - title: 'Graph direction', - dataIndex: 'graphDirection', - }, - { - title: 'Grafana', - dataIndex: 'grafanaStatus', - valueType: 'status' as any, - valueEnum: grafanaValueEnum, - }, - { - title: 'Configuration API', - dataIndex: 'configStatus', - valueType: 'status' as any, - valueEnum: configurationValueEnum, - }, - { - title: 'Configuration mode', - dataIndex: 'configMode', - render: (_, record) => record.configMode || 'unknown', - }, - { - title: 'Runtime workflow files', - dataIndex: 'runtimeWorkflowFiles', - valueType: 'digit', - }, - { - title: 'Primitive count', - dataIndex: 'primitiveCount', - valueType: 'digit', - }, - { - title: 'Default provider', - dataIndex: 'defaultProvider', - render: (_, record) => {record.defaultProvider || 'default'}, - }, - { - title: 'Configured targets', - dataIndex: 'observabilityTargetsConfigured', - valueType: 'digit', - }, - ]; - -const settingsProfileIdentityColumns: ProDescriptionsItemProps[] = - [ - { - title: 'Provider', - dataIndex: 'provider', - render: (_, record) => {record.provider}, - }, - { - title: 'Display name', - dataIndex: 'displayName', - }, - { - title: 'Email', - dataIndex: 'email', - render: (_, record) => record.email || 'n/a', - }, - { - title: 'Email status', - dataIndex: 'emailVerified', - render: (_, record) => ( - - {record.emailVerified} - - ), - }, - { - title: 'Subject', - dataIndex: 'subject', - render: (_, record) => ( - {record.subject} - ), - }, - ]; - -const settingsProfileAccessColumns: ProDescriptionsItemProps[] = - [ - { - title: 'Roles', - dataIndex: 'roles', - render: (_, record) => - record.roles.length > 0 ? ( - - {record.roles.map((role) => ( - {role} - ))} - - ) : ( - No roles - ), - }, - { - title: 'Groups', - dataIndex: 'groups', - render: (_, record) => - record.groups.length > 0 ? ( - - {record.groups.map((group) => ( - {group} - ))} - - ) : ( - No groups - ), - }, - { - title: 'Permissions', - dataIndex: 'permissions', - render: (_, record) => - record.permissions.length > 0 ? ( - - {record.permissions.map((permission) => ( - {permission} - ))} - - ) : ( - No permissions - ), - }, - { - title: 'Scope', - dataIndex: 'scope', - render: (_, record) => - record.scope ? ( - {record.scope} - ) : ( - No scope - ), - }, - { - title: 'Access token expires', - dataIndex: 'expiresAt', - }, - ]; - -const settingsHints: SettingsHintItem[] = [ - { - id: 'hint-runtime', - text: 'Runtime configuration is managed from this page and applies to the local tool host.', - }, - { - id: 'hint-workflow', - text: 'Workflow files in the workspace are backed by local home/repo paths and can be edited directly from this page.', - }, - { - id: 'hint-secrets', - text: 'Secrets and raw config writes are local-only management actions. Review JSON carefully before saving.', - }, -]; - -const studioAppearanceOptions: Array<{ - label: string; - value: StudioAppearanceTheme; -}> = [ - { label: 'Blue', value: 'blue' }, - { label: 'Coral', value: 'coral' }, - { label: 'Forest', value: 'forest' }, -]; - -const studioColorModeOptions: Array<{ - label: string; - value: StudioColorMode; -}> = [ - { label: 'Light', value: 'light' }, - { label: 'Dark', value: 'dark' }, -]; - -function workflowKey( - item: Pick, -): string { - return `${item.source}:${item.filename}`; -} - -function normalizeWorkflowSource(source: string): WorkflowDraftSource { - return source === 'repo' ? 'repo' : 'home'; -} - -function buildNewWorkflowTemplate(filename: string): string { - const normalizedName = filename - .replace(/\.(yaml|yml)$/i, '') - .replace(/[^A-Za-z0-9_]+/g, '_') - .replace(/^_+|_+$/g, '') - .toLowerCase(); - const workflowName = normalizedName || 'new_workflow'; - - return `name: ${workflowName}\ndescription: Draft workflow\nsteps:\n - id: start\n type: assign\n parameters:\n target: status\n value: ready\n`; -} - -function formatMcpArgs(args: string[]): string { - return args.join('\n'); -} - -function formatMcpEnv(env: Record): string { - return JSON.stringify(env, null, 2); -} - -function parseMcpArgs(value: string): string[] { - return value - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function parseMcpEnv(value: string): Record { - const trimmed = value.trim(); - if (!trimmed) { - return {}; - } - - const parsed = JSON.parse(trimmed) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('MCP env must be a JSON object.'); - } - - return Object.fromEntries( - Object.entries(parsed as Record).map(([key, entry]) => { - if (typeof entry !== 'string') { - throw new Error(`MCP env value for "${key}" must be a string.`); - } - - return [key, entry]; - }), - ); -} - -function formatProbeSummary(result: ConfigurationLlmProbeResult): string { - if (!result.ok) { - return result.error || 'Probe failed.'; - } - - if (result.models && result.models.length > 0) { - return `Discovered ${result.models.length} models.`; - } - - if (result.modelsCount !== undefined) { - return `Probe succeeded with ${result.modelsCount} models.`; - } - - return 'Probe succeeded.'; -} - -const SettingsPage: React.FC = () => { - const [messageApi, messageContextHolder] = message.useMessage(); - const queryClient = useQueryClient(); - const screens = Grid.useBreakpoint(); - const formRef = useRef | undefined>( - undefined, - ); - const authSession = useMemo(() => loadStoredAuthSession(), []); - const [preferences, setPreferences] = useState(() => - loadConsolePreferences(), - ); - const [selectedWorkflowKey, setSelectedWorkflowKey] = useState( - null, - ); - const [selectedLlmInstanceName, setSelectedLlmInstanceName] = useState< - string | null - >(null); - const [isNewLlmInstanceDraft, setIsNewLlmInstanceDraft] = useState(false); - const [workflowFilename, setWorkflowFilename] = useState(''); - const [workflowSource, setWorkflowSource] = - useState('home'); - const [workflowContent, setWorkflowContent] = useState(''); - const [llmInstanceNameDraft, setLlmInstanceNameDraft] = useState(''); - const [llmProviderTypeDraft, setLlmProviderTypeDraft] = useState(''); - const [llmModelDraft, setLlmModelDraft] = useState(''); - const [llmEndpointDraft, setLlmEndpointDraft] = useState(''); - const [llmApiKeyDraft, setLlmApiKeyDraft] = useState(''); - const [llmProbeResult, setLlmProbeResult] = - useState(null); - const [llmModelsResult, setLlmModelsResult] = - useState(null); - const [embeddingsEnabledDraft, setEmbeddingsEnabledDraft] = useState(true); - const [embeddingsProviderTypeDraft, setEmbeddingsProviderTypeDraft] = - useState('deepseek'); - const [embeddingsEndpointDraft, setEmbeddingsEndpointDraft] = useState( - 'https://dashscope.aliyuncs.com/compatible-mode/v1', - ); - const [embeddingsModelDraft, setEmbeddingsModelDraft] = - useState('text-embedding-v3'); - const [embeddingsApiKeyDraft, setEmbeddingsApiKeyDraft] = useState(''); - const [webSearchEnabledDraft, setWebSearchEnabledDraft] = useState(true); - const [webSearchProviderDraft, setWebSearchProviderDraft] = - useState('tavily'); - const [webSearchEndpointDraft, setWebSearchEndpointDraft] = useState(''); - const [webSearchTimeoutDraft, setWebSearchTimeoutDraft] = useState('15000'); - const [webSearchDepthDraft, setWebSearchDepthDraft] = useState('advanced'); - const [webSearchApiKeyDraft, setWebSearchApiKeyDraft] = useState(''); - const [skillsMpBaseUrlDraft, setSkillsMpBaseUrlDraft] = useState( - 'https://skillsmp.com', - ); - const [skillsMpApiKeyDraft, setSkillsMpApiKeyDraft] = useState(''); - const [selectedMcpName, setSelectedMcpName] = useState(null); - const [isNewMcpDraft, setIsNewMcpDraft] = useState(false); - const [mcpNameDraft, setMcpNameDraft] = useState(''); - const [mcpCommandDraft, setMcpCommandDraft] = useState(''); - const [mcpArgsDraft, setMcpArgsDraft] = useState(''); - const [mcpEnvDraft, setMcpEnvDraft] = useState('{}'); - const [mcpTimeoutDraft, setMcpTimeoutDraft] = useState('60000'); - const [configJsonDraft, setConfigJsonDraft] = useState(''); - const [connectorsJsonDraft, setConnectorsJsonDraft] = useState(''); - const [mcpJsonDraft, setMcpJsonDraft] = useState(''); - const [secretKeyDraft, setSecretKeyDraft] = useState(''); - const [secretValueDraft, setSecretValueDraft] = useState(''); - const [secretsJsonDraft, setSecretsJsonDraft] = useState(''); - const [pendingDefaultProvider, setPendingDefaultProvider] = useState(''); - - const workflowCatalogQuery = useQuery({ - queryKey: ['settings-workflow-catalog'], - queryFn: () => consoleApi.listWorkflowCatalog(), - }); - const capabilitiesQuery = useQuery({ - queryKey: ['settings-capabilities'], - queryFn: () => consoleApi.getCapabilities(), - }); - const configurationHealthQuery = useQuery({ - queryKey: ['settings-configuration-health'], - queryFn: () => configurationApi.getHealth(), - retry: false, - }); - const configurationSourceQuery = useQuery({ - queryKey: ['settings-configuration-source'], - queryFn: () => configurationApi.getSourceStatus(), - retry: false, - }); - const hasLocalRuntimeAccess = - configurationSourceQuery.data?.localRuntimeAccess ?? false; - const configurationWorkflowsQuery = useQuery({ - queryKey: ['settings-configuration-workflows'], - queryFn: () => configurationApi.listWorkflows('all'), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationLlmProvidersQuery = useQuery({ - queryKey: ['settings-configuration-llm-providers'], - queryFn: () => configurationApi.listLlmProviders(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationLlmInstancesQuery = useQuery({ - queryKey: ['settings-configuration-llm-instances'], - queryFn: () => configurationApi.listLlmInstances(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationLlmDefaultQuery = useQuery({ - queryKey: ['settings-configuration-llm-default'], - queryFn: () => configurationApi.getLlmDefault(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationEmbeddingsQuery = useQuery({ - queryKey: ['settings-configuration-embeddings'], - queryFn: () => configurationApi.getEmbeddingsStatus(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationWebSearchQuery = useQuery({ - queryKey: ['settings-configuration-websearch'], - queryFn: () => configurationApi.getWebSearchStatus(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationSkillsMpQuery = useQuery({ - queryKey: ['settings-configuration-skillsmp'], - queryFn: () => configurationApi.getSkillsMpStatus(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationSecp256k1Query = useQuery({ - queryKey: ['settings-configuration-secp256k1'], - queryFn: () => configurationApi.getSecp256k1Status(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationConfigRawQuery = useQuery({ - queryKey: ['settings-configuration-config-raw'], - queryFn: () => configurationApi.getConfigRaw(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationConnectorsRawQuery = useQuery({ - queryKey: ['settings-configuration-connectors-raw'], - queryFn: () => configurationApi.getConnectorsRaw(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationMcpServersQuery = useQuery({ - queryKey: ['settings-configuration-mcp-servers'], - queryFn: () => configurationApi.listMcpServers(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationMcpRawQuery = useQuery({ - queryKey: ['settings-configuration-mcp-raw'], - queryFn: () => configurationApi.getMcpRaw(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - const configurationSecretsRawQuery = useQuery({ - queryKey: ['settings-configuration-secrets-raw'], - queryFn: () => configurationApi.getSecretsRaw(), - enabled: hasLocalRuntimeAccess, - retry: false, - }); - - const selectedWorkflow = useMemo(() => { - const items = configurationWorkflowsQuery.data ?? []; - if (!selectedWorkflowKey) { - return items[0] ?? null; - } - - return ( - items.find((item) => workflowKey(item) === selectedWorkflowKey) ?? - items[0] ?? - null - ); - }, [configurationWorkflowsQuery.data, selectedWorkflowKey]); - - const selectedLlmInstance = useMemo(() => { - const items = configurationLlmInstancesQuery.data ?? []; - if (isNewLlmInstanceDraft) { - return null; - } - - if (!selectedLlmInstanceName) { - return items[0] ?? null; - } - - return ( - items.find((item) => item.name === selectedLlmInstanceName) ?? - items[0] ?? - null - ); - }, [ - configurationLlmInstancesQuery.data, - isNewLlmInstanceDraft, - selectedLlmInstanceName, - ]); - - const selectedMcpServer = useMemo(() => { - const items = configurationMcpServersQuery.data ?? []; - if (isNewMcpDraft) { - return null; - } - - if (!selectedMcpName) { - return items[0] ?? null; - } - - return ( - items.find((item) => item.name === selectedMcpName) ?? items[0] ?? null - ); - }, [configurationMcpServersQuery.data, isNewMcpDraft, selectedMcpName]); - - const workflowDetailQuery = useQuery({ - queryKey: [ - 'settings-configuration-workflow-detail', - selectedWorkflow?.filename, - selectedWorkflow?.source, - ], - queryFn: () => { - if (!selectedWorkflow) { - throw new Error('No workflow is selected.'); - } - - return configurationApi.getWorkflow( - selectedWorkflow.filename, - normalizeWorkflowSource(selectedWorkflow.source), - ); - }, - enabled: hasLocalRuntimeAccess && Boolean(selectedWorkflow), - retry: false, - }); - - const configurationLlmApiKeyQuery = useQuery({ - queryKey: ['settings-configuration-llm-api-key', selectedLlmInstance?.name], - queryFn: () => { - if (!selectedLlmInstance?.name) { - throw new Error('No LLM instance is selected.'); - } - - return configurationApi.getLlmApiKey(selectedLlmInstance.name); - }, - enabled: hasLocalRuntimeAccess && Boolean(selectedLlmInstance?.name), - retry: false, - }); - - useEffect(() => { - const items = configurationWorkflowsQuery.data ?? []; - if (items.length === 0) { - return; - } - - if (!selectedWorkflowKey) { - setSelectedWorkflowKey(workflowKey(items[0])); - return; - } - - if (!items.some((item) => workflowKey(item) === selectedWorkflowKey)) { - setSelectedWorkflowKey(workflowKey(items[0])); - } - }, [configurationWorkflowsQuery.data, selectedWorkflowKey]); - - useEffect(() => { - const items = configurationLlmInstancesQuery.data ?? []; - if (items.length === 0) { - return; - } - - if (isNewLlmInstanceDraft) { - return; - } - - if (!selectedLlmInstanceName) { - setSelectedLlmInstanceName(items[0].name); - return; - } - - if (!items.some((item) => item.name === selectedLlmInstanceName)) { - setSelectedLlmInstanceName(items[0].name); - } - }, [ - configurationLlmInstancesQuery.data, - isNewLlmInstanceDraft, - selectedLlmInstanceName, - ]); - - useEffect(() => { - const items = configurationMcpServersQuery.data ?? []; - if (items.length === 0) { - return; - } - - if (isNewMcpDraft) { - return; - } - - if (!selectedMcpName) { - setSelectedMcpName(items[0].name); - return; - } - - if (!items.some((item) => item.name === selectedMcpName)) { - setSelectedMcpName(items[0].name); - } - }, [configurationMcpServersQuery.data, isNewMcpDraft, selectedMcpName]); - - useEffect(() => { - if (!workflowDetailQuery.data) { - return; - } - - setWorkflowFilename(workflowDetailQuery.data.filename); - setWorkflowSource(normalizeWorkflowSource(workflowDetailQuery.data.source)); - setWorkflowContent(workflowDetailQuery.data.content); - }, [workflowDetailQuery.data]); - - useEffect(() => { - if (!selectedLlmInstance) { - return; - } - - setLlmInstanceNameDraft(selectedLlmInstance.name); - setLlmProviderTypeDraft(selectedLlmInstance.providerType); - setLlmModelDraft(selectedLlmInstance.model); - setLlmEndpointDraft(selectedLlmInstance.endpoint); - setLlmApiKeyDraft(''); - setLlmProbeResult(null); - setLlmModelsResult(null); - }, [selectedLlmInstance]); - - useEffect(() => { - if (configurationConfigRawQuery.data) { - setConfigJsonDraft(configurationConfigRawQuery.data.json); - } - }, [configurationConfigRawQuery.data]); - - useEffect(() => { - if (!configurationEmbeddingsQuery.data) { - return; - } - - setEmbeddingsEnabledDraft( - configurationEmbeddingsQuery.data.enabled ?? true, - ); - setEmbeddingsProviderTypeDraft( - configurationEmbeddingsQuery.data.providerType || 'deepseek', - ); - setEmbeddingsEndpointDraft( - configurationEmbeddingsQuery.data.endpoint || - 'https://dashscope.aliyuncs.com/compatible-mode/v1', - ); - setEmbeddingsModelDraft( - configurationEmbeddingsQuery.data.model || 'text-embedding-v3', - ); - setEmbeddingsApiKeyDraft(''); - }, [configurationEmbeddingsQuery.data]); - - useEffect(() => { - if (!configurationWebSearchQuery.data) { - return; - } - - setWebSearchEnabledDraft(configurationWebSearchQuery.data.enabled ?? true); - setWebSearchProviderDraft( - configurationWebSearchQuery.data.provider || 'tavily', - ); - setWebSearchEndpointDraft(configurationWebSearchQuery.data.endpoint || ''); - setWebSearchTimeoutDraft( - configurationWebSearchQuery.data.timeoutMs != null - ? String(configurationWebSearchQuery.data.timeoutMs) - : '15000', - ); - setWebSearchDepthDraft( - configurationWebSearchQuery.data.searchDepth || 'advanced', - ); - setWebSearchApiKeyDraft(''); - }, [configurationWebSearchQuery.data]); - - useEffect(() => { - if (!configurationSkillsMpQuery.data) { - return; - } - - setSkillsMpBaseUrlDraft( - configurationSkillsMpQuery.data.baseUrl || 'https://skillsmp.com', - ); - setSkillsMpApiKeyDraft(''); - }, [configurationSkillsMpQuery.data]); - - useEffect(() => { - if (!selectedMcpServer) { - return; - } - - setMcpNameDraft(selectedMcpServer.name); - setMcpCommandDraft(selectedMcpServer.command); - setMcpArgsDraft(formatMcpArgs(selectedMcpServer.args)); - setMcpEnvDraft(formatMcpEnv(selectedMcpServer.env)); - setMcpTimeoutDraft(String(selectedMcpServer.timeoutMs)); - }, [selectedMcpServer]); - - useEffect(() => { - if (configurationConnectorsRawQuery.data) { - setConnectorsJsonDraft(configurationConnectorsRawQuery.data.json); - } - }, [configurationConnectorsRawQuery.data]); - - useEffect(() => { - if (configurationMcpRawQuery.data) { - setMcpJsonDraft(configurationMcpRawQuery.data.json); - } - }, [configurationMcpRawQuery.data]); - - useEffect(() => { - if (configurationSecretsRawQuery.data) { - setSecretsJsonDraft(configurationSecretsRawQuery.data.json); - } - }, [configurationSecretsRawQuery.data]); - - useEffect(() => { - if (configurationLlmDefaultQuery.data) { - setPendingDefaultProvider(configurationLlmDefaultQuery.data); - } - }, [configurationLlmDefaultQuery.data]); - - const workflowOptions = useMemo( - () => - buildWorkflowCatalogOptions( - workflowCatalogQuery.data ?? [], - preferences.preferredWorkflow, - ), - [preferences.preferredWorkflow, workflowCatalogQuery.data], - ); - - const observabilityTargets = useMemo( - () => - buildObservabilityTargets(preferences, { - workflow: preferences.preferredWorkflow, - actorId: '', - commandId: '', - runId: '', - stepId: '', - }).map((target) => ({ - id: target.id, - label: target.label, - description: target.description, - status: target.status, - homeUrl: target.homeUrl, - exploreUrl: target.exploreUrl, - })), - [preferences], - ); - - const settingsSummary = useMemo( - () => ({ - preferredWorkflow: preferences.preferredWorkflow, - graphDirection: preferences.actorGraphDirection, - grafanaStatus: preferences.grafanaBaseUrl ? 'configured' : 'missing', - configStatus: configurationHealthQuery.isSuccess && hasLocalRuntimeAccess - ? 'ready' - : 'unavailable', - configMode: hasLocalRuntimeAccess - ? (configurationSourceQuery.data?.mode ?? '') - : 'restricted', - runtimeWorkflowFiles: hasLocalRuntimeAccess - ? (configurationWorkflowsQuery.data?.length ?? 0) - : 0, - primitiveCount: capabilitiesQuery.data?.primitives.length ?? 0, - defaultProvider: hasLocalRuntimeAccess - ? (configurationLlmDefaultQuery.data ?? '') - : '', - observabilityTargetsConfigured: observabilityTargets.filter( - (target) => target.status === 'configured', - ).length, - }), - [ - capabilitiesQuery.data?.primitives.length, - configurationHealthQuery.isSuccess, - configurationLlmDefaultQuery.data, - hasLocalRuntimeAccess, - configurationSourceQuery.data?.mode, - configurationWorkflowsQuery.data?.length, - observabilityTargets, - preferences, - ], - ); - - const settingsUsageNotes = useMemo( - () => - hasLocalRuntimeAccess - ? settingsHints - : [ - { - id: 'hint-runtime-restricted', - text: 'Local runtime configuration is hidden because this console is not connected through a loopback tool host.', - }, - ], - [hasLocalRuntimeAccess], - ); - - const profileIdentity = useMemo(() => { - if (!authSession) { - return null; - } - - return { - provider: 'NyxID', - displayName: - authSession.user.name || authSession.user.email || authSession.user.sub, - email: authSession.user.email ?? '', - subject: authSession.user.sub, - emailVerified: - authSession.user.email_verified === true - ? 'Verified' - : authSession.user.email_verified === false - ? 'Unverified' - : 'Unknown', - }; - }, [authSession]); - - const profileAccess = useMemo(() => { - if (!authSession) { - return null; - } - - return { - roles: authSession.user.roles ?? [], - groups: authSession.user.groups ?? [], - permissions: authSession.user.permissions ?? [], - scope: authSession.tokens.scope ?? '', - expiresAt: formatDateTime(authSession.tokens.expiresAt), - }; - }, [authSession]); - - const configurationPathRecords = useMemo(() => { - const doctor = configurationSourceQuery.data?.doctor; - if (!doctor) { - return []; - } - - return [ - { id: 'config', label: 'config.json', status: doctor.config }, - { id: 'secrets', label: 'secrets.json', status: doctor.secrets }, - { - id: 'workflows-home', - label: 'workflows (home)', - status: doctor.workflowsHome, - }, - { - id: 'workflows-repo', - label: 'workflows (repo)', - status: doctor.workflowsRepo, - }, - { id: 'connectors', label: 'connectors.json', status: doctor.connectors }, - { id: 'mcp', label: 'mcp.json', status: doctor.mcp }, - ]; - }, [configurationSourceQuery.data]); - - const saveWorkflowMutation = useMutation({ - mutationFn: () => - configurationApi.saveWorkflow({ - filename: workflowFilename, - content: workflowContent, - source: workflowSource, - }), - onSuccess: async (saved) => { - messageApi.success(`Saved ${saved.filename} to ${saved.source}.`); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-workflows'], - }), - queryClient.invalidateQueries({ - queryKey: [ - 'settings-configuration-workflow-detail', - saved.filename, - saved.source, - ], - }), - ]); - setSelectedWorkflowKey(workflowKey(saved)); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to save workflow.', - ); - }, - }); - - const deleteWorkflowMutation = useMutation({ - mutationFn: async () => { - await configurationApi.deleteWorkflow({ - filename: workflowFilename, - source: workflowSource, - }); - }, - onSuccess: async () => { - messageApi.success(`Deleted ${workflowFilename}.`); - setSelectedWorkflowKey(null); - setWorkflowFilename(''); - setWorkflowContent(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-workflows'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-workflow-detail'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to delete workflow.', - ); - }, - }); - - const saveConfigRawMutation = useMutation({ - mutationFn: () => configurationApi.saveConfigRaw(configJsonDraft), - onSuccess: async () => { - messageApi.success('config.json saved.'); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-config-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-websearch'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-skillsmp'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to save config.json.', - ); - }, - }); - - const saveConnectorsRawMutation = useMutation({ - mutationFn: () => configurationApi.saveConnectorsRaw(connectorsJsonDraft), - onSuccess: async () => { - messageApi.success('connectors.json saved.'); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-connectors-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to save connectors.json.', - ); - }, - }); - - const validateConnectorsRawMutation = useMutation({ - mutationFn: () => - configurationApi.validateConnectorsRaw(connectorsJsonDraft), - onSuccess: (result) => { - messageApi.success(`connectors.json is valid (${result.count} entries).`); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to validate connectors.json.', - ); - }, - }); - - const saveMcpRawMutation = useMutation({ - mutationFn: () => configurationApi.saveMcpRaw(mcpJsonDraft), - onSuccess: async () => { - messageApi.success('mcp.json saved.'); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-mcp-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to save mcp.json.', - ); - }, - }); - - const saveMcpServerMutation = useMutation({ - mutationFn: () => { - const timeoutMs = Number.parseInt(mcpTimeoutDraft.trim(), 10); - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error('MCP timeout must be a positive integer.'); - } - - return configurationApi.saveMcpServer({ - name: mcpNameDraft.trim(), - command: mcpCommandDraft.trim(), - args: parseMcpArgs(mcpArgsDraft), - env: parseMcpEnv(mcpEnvDraft), - timeoutMs, - }); - }, - onSuccess: async (server) => { - messageApi.success(`Saved MCP server ${server.name}.`); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-mcp-servers'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-mcp-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - setIsNewMcpDraft(false); - setSelectedMcpName(server.name); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to save MCP server.', - ); - }, - }); - - const validateMcpRawMutation = useMutation({ - mutationFn: () => configurationApi.validateMcpRaw(mcpJsonDraft), - onSuccess: (result) => { - messageApi.success(`mcp.json is valid (${result.count} servers).`); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to validate mcp.json.', - ); - }, - }); - - const deleteMcpServerMutation = useMutation({ - mutationFn: async () => { - await configurationApi.deleteMcpServer(mcpNameDraft.trim()); - }, - onSuccess: async () => { - messageApi.success(`Deleted MCP server ${mcpNameDraft.trim()}.`); - setIsNewMcpDraft(false); - setSelectedMcpName(null); - setMcpNameDraft(''); - setMcpCommandDraft(''); - setMcpArgsDraft(''); - setMcpEnvDraft('{}'); - setMcpTimeoutDraft('60000'); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-mcp-servers'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-mcp-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to delete MCP server.', - ); - }, - }); - - const saveSecretsRawMutation = useMutation({ - mutationFn: () => configurationApi.saveSecretsRaw(secretsJsonDraft), - onSuccess: async () => { - messageApi.success('secrets.json saved.'); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-api-key'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-embeddings'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-websearch'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-skillsmp'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secp256k1'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-providers'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-instances'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-default'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to save secrets.json.', - ); - }, - }); - - const saveLlmInstanceMutation = useMutation({ - mutationFn: () => - configurationApi.saveLlmInstance({ - providerName: llmInstanceNameDraft.trim(), - providerType: llmProviderTypeDraft.trim(), - model: llmModelDraft.trim(), - endpoint: llmEndpointDraft.trim() || undefined, - apiKey: llmApiKeyDraft.trim() || undefined, - }), - onSuccess: async () => { - const name = llmInstanceNameDraft.trim(); - messageApi.success(`Saved LLM instance ${name}.`); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-instances'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-providers'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-default'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-api-key', name], - }), - ]); - setIsNewLlmInstanceDraft(false); - setSelectedLlmInstanceName(name); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to save LLM instance.', - ); - }, - }); - - const deleteLlmInstanceMutation = useMutation({ - mutationFn: () => - configurationApi.deleteLlmInstance(llmInstanceNameDraft.trim()), - onSuccess: async () => { - const name = llmInstanceNameDraft.trim(); - messageApi.success(`Deleted LLM instance ${name}.`); - setIsNewLlmInstanceDraft(false); - setSelectedLlmInstanceName(null); - setLlmInstanceNameDraft(''); - setLlmProviderTypeDraft(''); - setLlmModelDraft(''); - setLlmEndpointDraft(''); - setLlmApiKeyDraft(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-instances'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-providers'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-default'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-api-key', name], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to delete LLM instance.', - ); - }, - }); - - const setLlmApiKeyMutation = useMutation({ - mutationFn: () => - configurationApi.setLlmApiKey({ - providerName: llmInstanceNameDraft.trim(), - apiKey: llmApiKeyDraft.trim(), - }), - onSuccess: async () => { - const name = llmInstanceNameDraft.trim(); - messageApi.success(`API key updated for ${name}.`); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-api-key', name], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-default'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-instances'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to update API key.', - ); - }, - }); - - const deleteLlmApiKeyMutation = useMutation({ - mutationFn: () => - configurationApi.deleteLlmApiKey(llmInstanceNameDraft.trim()), - onSuccess: async () => { - const name = llmInstanceNameDraft.trim(); - messageApi.success(`API key removed for ${name}.`); - setLlmApiKeyDraft(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-api-key', name], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-default'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-instances'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to remove API key.', - ); - }, - }); - - const revealLlmApiKeyMutation = useMutation({ - mutationFn: () => - configurationApi.getLlmApiKey(llmInstanceNameDraft.trim(), { - reveal: true, - }), - onSuccess: (result) => { - setLlmApiKeyDraft(result.value ?? ''); - messageApi.success(`Loaded API key for ${result.providerName}.`); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to reveal API key.', - ); - }, - }); - - const saveEmbeddingsMutation = useMutation({ - mutationFn: () => - configurationApi.saveEmbeddings({ - enabled: embeddingsEnabledDraft, - providerType: embeddingsProviderTypeDraft.trim(), - endpoint: embeddingsEndpointDraft.trim(), - model: embeddingsModelDraft.trim(), - apiKey: embeddingsApiKeyDraft.trim() || undefined, - }), - onSuccess: async () => { - messageApi.success('Embeddings configuration saved.'); - setEmbeddingsApiKeyDraft(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-embeddings'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to save embeddings configuration.', - ); - }, - }); - - const deleteEmbeddingsMutation = useMutation({ - mutationFn: () => configurationApi.deleteEmbeddings(), - onSuccess: async () => { - messageApi.success('Embeddings configuration deleted.'); - setEmbeddingsApiKeyDraft(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-embeddings'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to delete embeddings configuration.', - ); - }, - }); - - const revealEmbeddingsApiKeyMutation = - useMutation({ - mutationFn: () => configurationApi.getEmbeddingsApiKey({ reveal: true }), - onSuccess: (result) => { - setEmbeddingsApiKeyDraft(result.value ?? ''); - messageApi.success('Loaded embeddings API key.'); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to reveal embeddings API key.', - ); - }, - }); - - const saveWebSearchMutation = useMutation({ - mutationFn: () => { - const timeoutMs = webSearchTimeoutDraft.trim() - ? Number.parseInt(webSearchTimeoutDraft.trim(), 10) - : undefined; - if ( - webSearchTimeoutDraft.trim() && - (!Number.isFinite(timeoutMs) || Number(timeoutMs) <= 0) - ) { - throw new Error('Web search timeout must be a positive integer.'); - } - - return configurationApi.saveWebSearch({ - enabled: webSearchEnabledDraft, - provider: webSearchProviderDraft.trim(), - endpoint: webSearchEndpointDraft.trim() || undefined, - timeoutMs, - searchDepth: webSearchDepthDraft.trim() || undefined, - apiKey: webSearchApiKeyDraft.trim() || undefined, - }); - }, - onSuccess: async () => { - messageApi.success('Web search configuration saved.'); - setWebSearchApiKeyDraft(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-websearch'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-config-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to save web search configuration.', - ); - }, - }); - - const deleteWebSearchMutation = useMutation({ - mutationFn: () => configurationApi.deleteWebSearch(), - onSuccess: async () => { - messageApi.success('Web search configuration deleted.'); - setWebSearchApiKeyDraft(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-websearch'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-config-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to delete web search configuration.', - ); - }, - }); - - const revealWebSearchApiKeyMutation = - useMutation({ - mutationFn: () => configurationApi.getWebSearchApiKey({ reveal: true }), - onSuccess: (result) => { - setWebSearchApiKeyDraft(result.value ?? ''); - messageApi.success('Loaded web search API key.'); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to reveal web search API key.', - ); - }, - }); - - const saveSkillsMpMutation = useMutation({ - mutationFn: () => - configurationApi.saveSkillsMp({ - apiKey: skillsMpApiKeyDraft.trim() || undefined, - baseUrl: skillsMpBaseUrlDraft.trim() || undefined, - }), - onSuccess: async () => { - messageApi.success('SkillsMP configuration saved.'); - setSkillsMpApiKeyDraft(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-skillsmp'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-config-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to save SkillsMP configuration.', - ); - }, - }); - - const deleteSkillsMpMutation = useMutation({ - mutationFn: () => configurationApi.deleteSkillsMp(), - onSuccess: async () => { - messageApi.success('SkillsMP configuration deleted.'); - setSkillsMpApiKeyDraft(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-skillsmp'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-config-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to delete SkillsMP configuration.', - ); - }, - }); - - const revealSkillsMpApiKeyMutation = - useMutation({ - mutationFn: () => configurationApi.getSkillsMpApiKey({ reveal: true }), - onSuccess: (result) => { - setSkillsMpApiKeyDraft(result.value ?? ''); - messageApi.success('Loaded SkillsMP API key.'); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to reveal SkillsMP API key.', - ); - }, - }); - - const generateSecp256k1Mutation = useMutation({ - mutationFn: () => configurationApi.generateSecp256k1(), - onSuccess: async (result) => { - messageApi.success( - result.backedUp - ? 'Generated signer key and backed up the previous private key.' - : 'Generated signer key.', - ); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secp256k1'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-source'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to generate signer key.', - ); - }, - }); - - const probeLlmTestMutation = useMutation({ - mutationFn: () => - configurationApi.probeLlmTest({ - providerType: llmProviderTypeDraft.trim(), - endpoint: llmEndpointDraft.trim() || undefined, - apiKey: llmApiKeyDraft.trim(), - }), - onSuccess: (result) => { - setLlmProbeResult(result); - messageApi[result.ok ? 'success' : 'warning'](formatProbeSummary(result)); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to probe provider.', - ); - }, - }); - - const probeLlmModelsMutation = useMutation({ - mutationFn: () => - configurationApi.probeLlmModels({ - providerType: llmProviderTypeDraft.trim(), - endpoint: llmEndpointDraft.trim() || undefined, - apiKey: llmApiKeyDraft.trim(), - }), - onSuccess: (result) => { - setLlmModelsResult(result); - messageApi[result.ok ? 'success' : 'warning'](formatProbeSummary(result)); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to fetch models.', - ); - }, - }); - - const setLlmDefaultMutation = useMutation({ - mutationFn: () => configurationApi.setLlmDefault(pendingDefaultProvider), - onSuccess: async (providerName) => { - messageApi.success(`Default provider set to ${providerName}.`); - await queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-default'], - }); - }, - onError: (error) => { - messageApi.error( - error instanceof Error - ? error.message - : 'Failed to update default provider.', - ); - }, - }); - - const setSecretMutation = useMutation({ - mutationFn: () => - configurationApi.setSecret({ - key: secretKeyDraft.trim(), - value: secretValueDraft, - }), - onSuccess: async () => { - messageApi.success(`Secret ${secretKeyDraft.trim()} saved.`); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-api-key'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-embeddings'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-websearch'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-skillsmp'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secp256k1'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-providers'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-instances'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-default'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to save secret.', - ); - }, - }); - - const removeSecretMutation = useMutation({ - mutationFn: () => configurationApi.removeSecret(secretKeyDraft.trim()), - onSuccess: async () => { - messageApi.success(`Secret ${secretKeyDraft.trim()} removed.`); - setSecretValueDraft(''); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secrets-raw'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-api-key'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-embeddings'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-websearch'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-skillsmp'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-secp256k1'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-providers'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-instances'], - }), - queryClient.invalidateQueries({ - queryKey: ['settings-configuration-llm-default'], - }), - ]); - }, - onError: (error) => { - messageApi.error( - error instanceof Error ? error.message : 'Failed to remove secret.', - ); - }, - }); - - const handleSavePreferences = async (values: ConsolePreferences) => { - const next = saveConsolePreferences(values); - setPreferences(next); - messageApi.success('Console preferences saved.'); - return true; - }; - - const handleResetPreferences = () => { - const next = resetConsolePreferences(); - setPreferences(next); - formRef.current?.setFieldsValue(next); - messageApi.success('Console preferences reset to defaults.'); - }; - - const handleNewWorkflowDraft = () => { - const filename = 'new_workflow.yaml'; - setSelectedWorkflowKey(null); - setWorkflowFilename(filename); - setWorkflowSource('home'); - setWorkflowContent(buildNewWorkflowTemplate(filename)); - }; - - const handleNewLlmInstanceDraft = () => { - setIsNewLlmInstanceDraft(true); - setSelectedLlmInstanceName(null); - setLlmInstanceNameDraft('new-provider'); - setLlmProviderTypeDraft(configurationLlmProvidersQuery.data?.[0]?.id ?? ''); - setLlmModelDraft(''); - setLlmEndpointDraft(''); - setLlmApiKeyDraft(''); - setLlmProbeResult(null); - setLlmModelsResult(null); - }; - - const handleNewMcpServerDraft = () => { - setIsNewMcpDraft(true); - setSelectedMcpName(null); - setMcpNameDraft('new-mcp-server'); - setMcpCommandDraft(''); - setMcpArgsDraft(''); - setMcpEnvDraft('{}'); - setMcpTimeoutDraft('60000'); - }; - - const handleDeleteWorkflow = () => { - if (!workflowFilename) { - messageApi.warning('Select a workflow file before deleting.'); - return; - } - - if (!window.confirm(`Delete ${workflowFilename} from ${workflowSource}?`)) { - return; - } - - deleteWorkflowMutation.mutate(); - }; - - const handleDeleteLlmInstance = () => { - const name = llmInstanceNameDraft.trim(); - if (!name) { - messageApi.warning('Select an LLM instance before deleting.'); - return; - } - - if (!window.confirm(`Delete LLM instance ${name}?`)) { - return; - } - - deleteLlmInstanceMutation.mutate(); - }; - - const handleDeleteMcpServer = () => { - const name = mcpNameDraft.trim(); - if (!name) { - messageApi.warning('Select an MCP server before deleting.'); - return; - } - - if (!window.confirm(`Delete MCP server ${name}?`)) { - return; - } - - deleteMcpServerMutation.mutate(); - }; - - const handleDeleteEmbeddings = () => { - if ( - !window.confirm( - 'Delete embeddings configuration, including the stored API key?', - ) - ) { - return; - } - - deleteEmbeddingsMutation.mutate(); - }; - - const handleDeleteWebSearch = () => { - if ( - !window.confirm( - 'Delete web search configuration, including the stored API key?', - ) - ) { - return; - } - - deleteWebSearchMutation.mutate(); - }; - - const handleDeleteSkillsMp = () => { - if ( - !window.confirm( - 'Delete SkillsMP configuration, including the stored API key?', - ) - ) { - return; - } - - deleteSkillsMpMutation.mutate(); - }; - - const handleGenerateSecp256k1 = () => { - if ( - !window.confirm( - 'Generate a new secp256k1 private key and save it locally? Existing material will be backed up automatically.', - ) - ) { - return; - } - - generateSecp256k1Mutation.mutate(); - }; - - const llmDefaultOptions = useMemo( - () => - (configurationLlmInstancesQuery.data ?? []).map((item) => ({ - label: `${item.name} · ${item.providerDisplayName}`, - value: item.name, - })), - [configurationLlmInstancesQuery.data], - ); - - const llmProviderTypeOptions = useMemo( - () => - (configurationLlmProvidersQuery.data ?? []).map((item) => ({ - label: `${item.displayName} · ${item.category}`, - value: item.id, - })), - [configurationLlmProvidersQuery.data], - ); - - const workspaceTabs = [ - { - key: 'system', - label: 'System status', - children: ( - - {configurationSourceQuery.isError ? ( - - ) : null} - - - rowKey="id" - search={false} - split - dataSource={configurationPathRecords} - locale={{ - emptyText: ( - - ), - }} - metas={{ - title: { - dataIndex: 'label', - render: (_, record) => ( - - {record.label} - - {record.status.exists ? 'exists' : 'missing'} - - - {record.status.writable ? 'writable' : 'read-only'} - - - ), - }, - description: { - render: (_, record) => ( - - {record.status.path} - {record.status.error ? ( - - {record.status.error} - - ) : null} - - ), - }, - subTitle: { - render: (_, record) => ( - - size {record.status.sizeBytes ?? 0} bytes - - ), - }, - }} - /> - - ), - }, - { - key: 'workflows', - label: 'Workflow files', - children: ( - - - configurationWorkflowsQuery.refetch()}> - Refresh - - } - > - - rowKey={(record) => workflowKey(record)} - search={false} - split - dataSource={configurationWorkflowsQuery.data ?? []} - locale={{ - emptyText: ( - - ), - }} - metas={{ - title: { - dataIndex: 'filename', - render: (_, record) => ( - - - {record.source} - - ), - }, - description: { - render: (_, record) => ( - - - {formatDateTime(record.lastModified)} - - {record.path} - - ), - }, - }} - /> - - - - - - - - setWorkflowFilename(event.target.value) - } - /> - - style={{ width: 140 }} - value={workflowSource} - onChange={setWorkflowSource} - options={[ - { label: 'home', value: 'home' }, - { label: 'repo', value: 'repo' }, - ]} - /> - - - - - - setWorkflowContent(event.target.value)} - placeholder="name: workflow_name" - /> - - Files are loaded from both home and repo roots. Save writes to - the selected target source. - - - - - - ), - }, - { - key: 'embeddings', - label: 'Embeddings', - children: ( - - - - - {configurationEmbeddingsQuery.data?.configured - ? 'API key configured' - : 'API key missing'} - - {embeddingsEnabledDraft ? 'enabled' : 'disabled'} - {configurationEmbeddingsQuery.data?.masked ? ( - {configurationEmbeddingsQuery.data.masked} - ) : null} - - - - - setEmbeddingsProviderTypeDraft(event.target.value) - } - /> - - setEmbeddingsEndpointDraft(event.target.value) - } - /> - setEmbeddingsModelDraft(event.target.value)} - /> - - - setEmbeddingsApiKeyDraft(event.target.value)} - /> - - - - - - Stored keys live under{' '} - LLMProviders:Embeddings:*. - - - ), - }, - { - key: 'websearch', - label: 'Web Search', - children: ( - - - - - {configurationWebSearchQuery.data?.configured - ? 'API key configured' - : 'API key missing'} - - {webSearchEnabledDraft ? 'enabled' : 'disabled'} - {configurationWebSearchQuery.data?.masked ? ( - {configurationWebSearchQuery.data.masked} - ) : null} - - - - - setWebSearchProviderDraft(event.target.value) - } - /> - - setWebSearchEndpointDraft(event.target.value) - } - /> - setWebSearchTimeoutDraft(event.target.value)} - /> - setWebSearchDepthDraft(event.target.value)} - /> - - - setWebSearchApiKeyDraft(event.target.value)} - /> - - - - - - Non-secret fields are written to{' '} - config.json. The key stays - in secrets.json. - - - ), - }, - { - key: 'skillsmp', - label: 'SkillsMP', - children: ( - - - - - {configurationSkillsMpQuery.data?.configured - ? 'API key configured' - : 'API key missing'} - - {configurationSkillsMpQuery.data?.masked ? ( - {configurationSkillsMpQuery.data.masked} - ) : null} - - - - setSkillsMpBaseUrlDraft(event.target.value)} - /> - setSkillsMpApiKeyDraft(event.target.value)} - /> - - - - - - - - API key path:{' '} - - {configurationSkillsMpQuery.data?.keyPath ?? 'SkillsMP:ApiKey'} - - - - ), - }, - { - key: 'signer-key', - label: 'Signer key', - children: ( - - - - - {configurationSecp256k1Query.data?.configured - ? 'configured' - : 'not configured'} - - - backups:{' '} - {configurationSecp256k1Query.data?.privateKey.backupCount ?? 0} - - - - - - Public key - - {configurationSecp256k1Query.data?.publicKey.hex ? ( - - Copy public key - - ) : null} - - - Private key status - - - Stored at{' '} - - {configurationSecp256k1Query.data?.privateKey.keyPath ?? - 'Crypto:EcdsaSecp256k1:PrivateKeyHex'} - - - - - ), - }, - { - key: 'connectors', - label: 'Connectors', - children: ( - - - - - {configurationConnectorsRawQuery.data?.exists - ? 'connectors.json present' - : 'connectors.json missing'} - - - entries: {configurationConnectorsRawQuery.data?.count ?? 0} - - - - - - setConnectorsJsonDraft(event.target.value)} - placeholder={'{\n "connectors": []\n}'} - /> - {configurationConnectorsRawQuery.data?.path ? ( - - Path:{' '} - - {configurationConnectorsRawQuery.data.path} - - - ) : null} - - ), - }, - { - key: 'llm', - label: 'LLM providers', - children: ( - - - - Default provider: {configurationLlmDefaultQuery.data ?? 'default'} - - - setLlmInstanceNameDraft(event.target.value) - } - /> - setLlmModelDraft(event.target.value)} - /> - setLlmEndpointDraft(event.target.value)} - /> - - - - - - - - setLlmApiKeyDraft(event.target.value)} - /> - - - - - - - {configurationLlmApiKeyQuery.data?.configured - ? 'api key configured' - : 'api key missing'} - - {configurationLlmApiKeyQuery.data?.masked ? ( - {configurationLlmApiKeyQuery.data.masked} - ) : null} - - - - - - {llmProbeResult ? ( - - - {formatProbeSummary(llmProbeResult)} - - {llmProbeResult.sampleModels?.length ? ( - - {llmProbeResult.sampleModels.join(', ')} - - ) : null} - - } - /> - ) : null} - {llmModelsResult ? ( - - - {formatProbeSummary(llmModelsResult)} - - {llmModelsResult.models?.length ? ( - - {llmModelsResult.models.slice(0, 10).join(', ')} - - ) : null} -
- } - /> - ) : null} -
- - - - ), - }} - metas={{ - title: { - dataIndex: 'name', - render: (_, record) => ( - - - {record.providerDisplayName} - - ), - }, - description: { - render: (_, record) => ( - - Model: {record.model} - {record.endpoint} - - ), - }, - }} - /> - - - - ), - }} - metas={{ - title: { - dataIndex: 'displayName', - render: (_, record) => ( - - - {record.displayName} - - {record.category} - {record.recommended ? ( - recommended - ) : null} - - ), - }, - description: { - render: (_, record) => ( - {record.description} - ), - }, - subTitle: { - render: (_, record) => ( - - configured instances: {record.configuredInstancesCount} - - ), - }, - }} - /> - - - ), - }, - { - key: 'mcp', - label: 'MCP', - children: ( - - - - - configurationMcpServersQuery.refetch()} - > - Refresh - - } - > - - rowKey="name" - search={false} - split - dataSource={configurationMcpServersQuery.data ?? []} - locale={{ - emptyText: ( - - ), - }} - metas={{ - title: { - dataIndex: 'name', - render: (_, record) => ( - - - {record.timeoutMs} ms - - ), - }, - description: { - render: (_, record) => ( - - - {record.command} - - - args: {record.args.length} · env:{' '} - {Object.keys(record.env).length} - - - ), - }, - }} - /> - - - - - - - setMcpNameDraft(event.target.value)} - /> - - setMcpCommandDraft(event.target.value) - } - /> - - setMcpTimeoutDraft(event.target.value) - } - /> - - - - - - Args - setMcpArgsDraft(event.target.value)} - placeholder={'node\nserver.js\n--transport\nstdio'} - /> - - One argument per line. - - - - Env - setMcpEnvDraft(event.target.value)} - placeholder={'{\n "API_KEY": "value"\n}'} - /> - - Provide a JSON object with string values. - - - - - - - - - - - {configurationMcpRawQuery.data?.exists - ? 'mcp.json present' - : 'mcp.json missing'} - - servers: {configurationMcpRawQuery.data?.count ?? 0} - - - - - setMcpJsonDraft(event.target.value)} - placeholder={'{\n "mcpServers": {}\n}'} - /> - {configurationMcpRawQuery.data?.path ? ( - - Path:{' '} - - {configurationMcpRawQuery.data.path} - - - ) : null} - - - - ), - }, - { - key: 'secrets', - label: 'Secrets', - children: ( - - - - - - setSecretKeyDraft(event.target.value)} - /> - setSecretValueDraft(event.target.value)} - /> - - - - - Use key-based operations for small targeted changes. Raw JSON - remains available below for bulk edits. - - - - - - - - setSecretsJsonDraft(event.target.value)} - placeholder={'{\n "LLMProviders": {}\n}'} - /> - - ), - }, - { - key: 'config', - label: 'Raw config', - children: ( - - - This editor writes the local runtime config JSON directly. - - - - - - setConfigJsonDraft(event.target.value)} - placeholder={'{\n "Workflow": {}\n}'} - /> - - ), - }, - ]; - - const summaryColumnCount = screens.xxl - ? 4 - : screens.lg - ? 3 - : screens.md - ? 2 - : 1; - const workspaceTabPlacement = screens.xl ? 'start' : 'top'; - const settingsSectionPlacement = screens.md ? 'top' : 'top'; - - const runtimeWorkspaceContent = ( - - - - ); - - const profileContent = ( - - - - {authSession && profileIdentity ? ( - - - - {profileIdentity.displayName.slice(0, 1).toUpperCase()} - - - - {profileIdentity.displayName} - - - {profileIdentity.email || profileIdentity.subject} - - - - - column={1} - dataSource={profileIdentity} - columns={settingsProfileIdentityColumns} - /> - - ) : ( - - )} - - - - - {profileAccess ? ( - - column={1} - dataSource={profileAccess} - columns={settingsProfileAccessColumns} - /> - ) : ( - - )} - - - - ); - - const consolePreferencesContent = ( - - - - - formRef={formRef} - layout="vertical" - initialValues={preferences} - onFinish={handleSavePreferences} - submitter={{ - render: (props) => ( - - - - - - ), - }} - > - - - - - - name="studioAppearanceTheme" - label="Studio accent" - options={studioAppearanceOptions} - rules={[ - { - required: true, - message: 'Studio accent is required.', - }, - ]} - /> - - - - name="studioColorMode" - label="Studio color mode" - options={studioColorModeOptions} - rules={[ - { - required: true, - message: 'Studio color mode is required.', - }, - ]} - /> - - - - Studio appearance is now a console-level preference instead of - a Studio runtime setting. - - - - - - - Loading workflows... - - ) : ( - - ), - }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - name="actorGraphDirection" - label="Actor graph direction" - options={[ - { label: 'Both', value: 'Both' }, - { label: 'Outbound', value: 'Outbound' }, - { label: 'Inbound', value: 'Inbound' }, - ]} - rules={[ - { - required: true, - message: 'Graph direction is required.', - }, - ]} - /> - - - - - - - - - - - - rowKey="id" - search={false} - split - dataSource={observabilityTargets} - locale={{ - emptyText: ( - - ), - }} - metas={{ - title: { - dataIndex: 'label', - render: (_, record) => ( - - {record.label} - - {record.status} - - - ), - }, - description: { - dataIndex: 'description', - }, - subTitle: { - render: (_, record) => - record.homeUrl ? ( - {record.homeUrl} - ) : ( - No URL configured - ), - }, - actions: { - render: (_, record) => [ - , - , - ], - }, - }} - /> - - - - {settingsUsageNotes.map((item) => ( - {item.text} - ))} - - - - - - ); - - const settingsSectionTabs = [ - { - key: 'profile', - label: 'Profile', - children: profileContent, - }, - { - key: 'preferences', - label: 'Console preferences', - children: consolePreferencesContent, - }, - ...(hasLocalRuntimeAccess - ? [ - { - key: 'runtime', - label: 'Runtime configuration', - children: runtimeWorkspaceContent, - }, - ] - : []), - ]; - - return ( - - {messageContextHolder} - - - - column={summaryColumnCount} - dataSource={settingsSummary} - columns={settingsSummaryColumns} - /> - - - - - - - ); -}; - -export default SettingsPage; diff --git a/apps/aevatar-console-web/src/pages/settings/index.test.tsx b/apps/aevatar-console-web/src/pages/settings/runtime.test.tsx similarity index 51% rename from apps/aevatar-console-web/src/pages/settings/index.test.tsx rename to apps/aevatar-console-web/src/pages/settings/runtime.test.tsx index ce1ddf23..267df0a3 100644 --- a/apps/aevatar-console-web/src/pages/settings/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/settings/runtime.test.tsx @@ -1,72 +1,56 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import React from 'react'; -import { configurationApi } from '@/shared/api/configurationApi'; -import { consoleApi } from '@/shared/api/consoleApi'; -import { persistAuthSession } from '@/shared/auth/session'; -import { renderWithQueryClient } from '../../../tests/reactQueryTestUtils'; -import SettingsPage from './index'; +import { screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { configurationApi } from "@/shared/api/configurationApi"; +import { runtimeQueryApi } from "@/shared/api/runtimeQueryApi"; +import { persistAuthSession } from "@/shared/auth/session"; +import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; +import RuntimeSettingsPage from "./runtime"; -jest.mock('@/shared/api/consoleApi', () => ({ - consoleApi: { - listWorkflowCatalog: jest.fn(async () => [ - { - name: 'incident_triage', - description: 'Incident triage', - category: 'ops', - group: 'starter', - groupLabel: 'Starter', - sortOrder: 1, - source: 'home', - sourceLabel: 'Saved', - showInLibrary: true, - isPrimitiveExample: false, - requiresLlmProvider: true, - primitives: ['llm_call'], - }, - ]), +jest.mock("@/shared/api/runtimeQueryApi", () => ({ + runtimeQueryApi: { getCapabilities: jest.fn(async () => ({ - schemaVersion: 'capabilities.v1', - generatedAtUtc: '2026-03-13T00:00:00Z', - primitives: [{ name: 'llm_call' }], + schemaVersion: "capabilities.v1", + generatedAtUtc: "2026-03-13T00:00:00Z", + primitives: [{ name: "llm_call" }], connectors: [], workflows: [], })), }, })); -jest.mock('@/shared/api/configurationApi', () => ({ +jest.mock("@/shared/api/configurationApi", () => ({ configurationApi: { - getHealth: jest.fn(async () => 'ok'), + getHealth: jest.fn(async () => "ok"), getSourceStatus: jest.fn(async () => ({ - mode: 'file', + mode: "file", mongoConfigured: false, fileConfigured: true, localRuntimeAccess: true, paths: { - root: '/tmp/.aevatar', - secretsJson: '/tmp/.aevatar/secrets.json', - configJson: '/tmp/.aevatar/config.json', - connectorsJson: '/tmp/.aevatar/connectors.json', - mcpJson: '/tmp/.aevatar/mcp.json', - workflowsHome: '/tmp/.aevatar/workflows', - workflowsRepo: '/repo/workflows', + root: "/tmp/.aevatar", + secretsJson: "/tmp/.aevatar/secrets.json", + configJson: "/tmp/.aevatar/config.json", + connectorsJson: "/tmp/.aevatar/connectors.json", + mcpJson: "/tmp/.aevatar/mcp.json", + workflowsHome: "/tmp/.aevatar/workflows", + workflowsRepo: "/repo/workflows", homeEnvValue: null, secretsPathEnvValue: null, }, doctor: { paths: { - root: '/tmp/.aevatar', - secretsJson: '/tmp/.aevatar/secrets.json', - configJson: '/tmp/.aevatar/config.json', - connectorsJson: '/tmp/.aevatar/connectors.json', - mcpJson: '/tmp/.aevatar/mcp.json', - workflowsHome: '/tmp/.aevatar/workflows', - workflowsRepo: '/repo/workflows', + root: "/tmp/.aevatar", + secretsJson: "/tmp/.aevatar/secrets.json", + configJson: "/tmp/.aevatar/config.json", + connectorsJson: "/tmp/.aevatar/connectors.json", + mcpJson: "/tmp/.aevatar/mcp.json", + workflowsHome: "/tmp/.aevatar/workflows", + workflowsRepo: "/repo/workflows", homeEnvValue: null, secretsPathEnvValue: null, }, secrets: { - path: '/tmp/.aevatar/secrets.json', + path: "/tmp/.aevatar/secrets.json", exists: true, readable: true, writable: true, @@ -74,7 +58,7 @@ jest.mock('@/shared/api/configurationApi', () => ({ error: null, }, config: { - path: '/tmp/.aevatar/config.json', + path: "/tmp/.aevatar/config.json", exists: true, readable: true, writable: true, @@ -82,7 +66,7 @@ jest.mock('@/shared/api/configurationApi', () => ({ error: null, }, connectors: { - path: '/tmp/.aevatar/connectors.json', + path: "/tmp/.aevatar/connectors.json", exists: false, readable: true, writable: true, @@ -90,7 +74,7 @@ jest.mock('@/shared/api/configurationApi', () => ({ error: null, }, mcp: { - path: '/tmp/.aevatar/mcp.json', + path: "/tmp/.aevatar/mcp.json", exists: false, readable: true, writable: true, @@ -98,7 +82,7 @@ jest.mock('@/shared/api/configurationApi', () => ({ error: null, }, workflowsHome: { - path: '/tmp/.aevatar/workflows', + path: "/tmp/.aevatar/workflows", exists: true, readable: true, writable: true, @@ -106,7 +90,7 @@ jest.mock('@/shared/api/configurationApi', () => ({ error: null, }, workflowsRepo: { - path: '/repo/workflows', + path: "/repo/workflows", exists: true, readable: true, writable: false, @@ -117,44 +101,44 @@ jest.mock('@/shared/api/configurationApi', () => ({ })), listWorkflows: jest.fn(async () => [ { - filename: 'incident_triage.yaml', - source: 'home', - path: '/tmp/.aevatar/workflows/incident_triage.yaml', + filename: "incident_triage.yaml", + source: "home", + path: "/tmp/.aevatar/workflows/incident_triage.yaml", sizeBytes: 128, - lastModified: '2026-03-13T00:00:00Z', + lastModified: "2026-03-13T00:00:00Z", }, ]), getWorkflow: jest.fn(async () => ({ - filename: 'incident_triage.yaml', - source: 'home', - path: '/tmp/.aevatar/workflows/incident_triage.yaml', + filename: "incident_triage.yaml", + source: "home", + path: "/tmp/.aevatar/workflows/incident_triage.yaml", sizeBytes: 128, - lastModified: '2026-03-13T00:00:00Z', - content: 'name: incident_triage\nsteps: []\n', + lastModified: "2026-03-13T00:00:00Z", + content: "name: incident_triage\nsteps: []\n", })), listLlmProviders: jest.fn(async () => [ { - id: 'openai', - displayName: 'OpenAI', - category: 'general', - description: 'OpenAI-compatible provider', + id: "openai", + displayName: "OpenAI", + category: "general", + description: "OpenAI-compatible provider", recommended: true, configuredInstancesCount: 1, }, ]), listLlmInstances: jest.fn(async () => [ { - name: 'default', - providerType: 'openai', - providerDisplayName: 'OpenAI', - model: 'gpt-test', - endpoint: 'https://api.example.com', + name: "default", + providerType: "openai", + providerDisplayName: "OpenAI", + model: "gpt-test", + endpoint: "https://api.example.com", }, ]), getLlmApiKey: jest.fn(async () => ({ - providerName: 'default', + providerName: "default", configured: true, - masked: 'sk-****1234', + masked: "sk-****1234", })), setLlmApiKey: jest.fn(), deleteLlmApiKey: jest.fn(), @@ -162,50 +146,50 @@ jest.mock('@/shared/api/configurationApi', () => ({ deleteLlmInstance: jest.fn(), probeLlmTest: jest.fn(), probeLlmModels: jest.fn(), - getLlmDefault: jest.fn(async () => 'default'), + getLlmDefault: jest.fn(async () => "default"), getEmbeddingsStatus: jest.fn(async () => ({ enabled: true, - providerType: 'deepseek', - endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - model: 'text-embedding-v3', + providerType: "deepseek", + endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "text-embedding-v3", configured: true, - masked: 'sk-****1234', + masked: "sk-****1234", })), getEmbeddingsApiKey: jest.fn(async () => ({ configured: true, - masked: 'sk-****1234', - keyPath: 'LLMProviders:Embeddings:ApiKey', + masked: "sk-****1234", + keyPath: "LLMProviders:Embeddings:ApiKey", })), saveEmbeddings: jest.fn(), deleteEmbeddings: jest.fn(), getWebSearchStatus: jest.fn(async () => ({ enabled: true, effectiveEnabled: true, - provider: 'tavily', - endpoint: 'https://api.tavily.com', + provider: "tavily", + endpoint: "https://api.tavily.com", timeoutMs: 15000, - searchDepth: 'advanced', + searchDepth: "advanced", configured: true, - masked: 'tv-****1234', + masked: "tv-****1234", available: true, })), getWebSearchApiKey: jest.fn(async () => ({ configured: true, - masked: 'tv-****1234', - keyPath: 'Aevatar:Tools:WebSearch:ApiKey', + masked: "tv-****1234", + keyPath: "Aevatar:Tools:WebSearch:ApiKey", })), saveWebSearch: jest.fn(), deleteWebSearch: jest.fn(), getSkillsMpStatus: jest.fn(async () => ({ configured: true, - masked: 'sm-****1234', - keyPath: 'SkillsMP:ApiKey', - baseUrl: 'https://skillsmp.com', + masked: "sm-****1234", + keyPath: "SkillsMP:ApiKey", + baseUrl: "https://skillsmp.com", })), getSkillsMpApiKey: jest.fn(async () => ({ configured: true, - masked: 'sm-****1234', - keyPath: 'SkillsMP:ApiKey', + masked: "sm-****1234", + keyPath: "SkillsMP:ApiKey", })), saveSkillsMp: jest.fn(), deleteSkillsMp: jest.fn(), @@ -213,14 +197,14 @@ jest.mock('@/shared/api/configurationApi', () => ({ configured: true, privateKey: { configured: true, - masked: 'priv****mask', - keyPath: 'Crypto:EcdsaSecp256k1:PrivateKeyHex', - backupsPrefix: 'Crypto:EcdsaSecp256k1:Backups:', + masked: "priv****mask", + keyPath: "Crypto:EcdsaSecp256k1:PrivateKeyHex", + backupsPrefix: "Crypto:EcdsaSecp256k1:Backups:", backupCount: 1, }, publicKey: { configured: true, - hex: '04abcdef', + hex: "04abcdef", }, })), generateSecp256k1: jest.fn(), @@ -233,16 +217,16 @@ jest.mock('@/shared/api/configurationApi', () => ({ json: '{\n "connectors": []\n}', count: 0, exists: false, - path: '/tmp/.aevatar/connectors.json', + path: "/tmp/.aevatar/connectors.json", })), validateConnectorsRaw: jest.fn(), saveConnectorsRaw: jest.fn(), listMcpServers: jest.fn(async () => [ { - name: 'local-tools', - command: 'node', - args: ['server.js'], - env: { API_KEY: 'masked' }, + name: "local-tools", + command: "node", + args: ["server.js"], + env: { API_KEY: "masked" }, timeoutMs: 60000, }, ]), @@ -252,7 +236,7 @@ jest.mock('@/shared/api/configurationApi', () => ({ json: '{\n "mcpServers": {}\n}', count: 0, exists: false, - path: '/tmp/.aevatar/mcp.json', + path: "/tmp/.aevatar/mcp.json", })), validateMcpRaw: jest.fn(), saveMcpRaw: jest.fn(), @@ -270,55 +254,41 @@ jest.mock('@/shared/api/configurationApi', () => ({ }, })); -describe('SettingsPage', () => { +describe("RuntimeSettingsPage", () => { beforeEach(() => { window.localStorage.clear(); jest.clearAllMocks(); persistAuthSession({ tokens: { - accessToken: 'token-1', - tokenType: 'Bearer', + accessToken: "token-1", + tokenType: "Bearer", expiresIn: 3600, expiresAt: Date.now() + 3600_000, - scope: 'openid profile email', + scope: "openid profile email", }, user: { - sub: 'nyxid-user-1', - email: 'potter@example.com', + sub: "nyxid-user-1", + email: "potter@example.com", email_verified: true, - name: 'Potter Sun', - roles: ['admin'], - groups: ['console'], - permissions: ['settings:write'], + name: "Potter Sun", + roles: ["admin"], + groups: ["console"], + permissions: ["settings:write"], }, }); }); - it('renders the signed-in NyxID profile in the settings workspace', async () => { - renderWithQueryClient(React.createElement(SettingsPage)); - - const profileTab = await screen.findByRole('tab', { name: 'Profile' }); - fireEvent.click(profileTab); - - expect(await screen.findByText('Account profile')).toBeTruthy(); - expect(screen.getAllByText('Potter Sun').length).toBeGreaterThan(0); - expect(screen.getAllByText('potter@example.com').length).toBeGreaterThan(0); - expect(screen.getByText('settings:write')).toBeTruthy(); - expect(screen.getByText('openid profile email')).toBeTruthy(); - }); - - it('renders the runtime configuration workspace backed by the configuration capability', async () => { - renderWithQueryClient(React.createElement(SettingsPage)); + it("renders the runtime configuration workspace backed by the configuration capability", async () => { + renderWithQueryClient(React.createElement(RuntimeSettingsPage)); await waitFor(() => { - expect(consoleApi.listWorkflowCatalog).toHaveBeenCalled(); - expect(consoleApi.getCapabilities).toHaveBeenCalled(); + expect(runtimeQueryApi.getCapabilities).toHaveBeenCalled(); expect(configurationApi.getHealth).toHaveBeenCalled(); expect(configurationApi.getSourceStatus).toHaveBeenCalled(); expect(configurationApi.listWorkflows).toHaveBeenCalled(); expect(configurationApi.getWorkflow).toHaveBeenCalled(); expect(configurationApi.getLlmDefault).toHaveBeenCalled(); - expect(configurationApi.getLlmApiKey).toHaveBeenCalledWith('default'); + expect(configurationApi.getLlmApiKey).toHaveBeenCalledWith("default"); expect(configurationApi.getEmbeddingsStatus).toHaveBeenCalled(); expect(configurationApi.getWebSearchStatus).toHaveBeenCalled(); expect(configurationApi.getSkillsMpStatus).toHaveBeenCalled(); @@ -330,93 +300,93 @@ describe('SettingsPage', () => { expect(configurationApi.getSecretsRaw).toHaveBeenCalled(); }); - expect( - await screen.findByRole('tab', { name: 'Runtime configuration' }), - ).toBeTruthy(); - expect(screen.queryByText('Local config tool')).toBeNull(); + expect(screen.queryByRole("tab", { name: "Profile" })).toBeNull(); + expect(screen.queryByRole("tab", { name: "Console settings" })).toBeNull(); + expect(screen.getByText("Runtime environment summary")).toBeTruthy(); + expect(screen.getByText("Runtime configuration workspace")).toBeTruthy(); }); - it('hides runtime configuration when local runtime access is unavailable', async () => { + it("shows an unavailable warning when local runtime access is missing", async () => { jest.mocked(configurationApi.getSourceStatus).mockResolvedValueOnce({ - mode: 'restricted', + mode: "restricted", mongoConfigured: false, fileConfigured: false, localRuntimeAccess: false, paths: { - root: '', - secretsJson: '', - configJson: '', - connectorsJson: '', - mcpJson: '', - workflowsHome: '', - workflowsRepo: '', + root: "", + secretsJson: "", + configJson: "", + connectorsJson: "", + mcpJson: "", + workflowsHome: "", + workflowsRepo: "", homeEnvValue: null, secretsPathEnvValue: null, }, doctor: { paths: { - root: '', - secretsJson: '', - configJson: '', - connectorsJson: '', - mcpJson: '', - workflowsHome: '', - workflowsRepo: '', + root: "", + secretsJson: "", + configJson: "", + connectorsJson: "", + mcpJson: "", + workflowsHome: "", + workflowsRepo: "", homeEnvValue: null, secretsPathEnvValue: null, }, secrets: { - path: '', + path: "", exists: false, readable: false, writable: false, sizeBytes: null, - error: 'Local runtime access is required.', + error: "Local runtime access is required.", }, config: { - path: '', + path: "", exists: false, readable: false, writable: false, sizeBytes: null, - error: 'Local runtime access is required.', + error: "Local runtime access is required.", }, connectors: { - path: '', + path: "", exists: false, readable: false, writable: false, sizeBytes: null, - error: 'Local runtime access is required.', + error: "Local runtime access is required.", }, mcp: { - path: '', + path: "", exists: false, readable: false, writable: false, sizeBytes: null, - error: 'Local runtime access is required.', + error: "Local runtime access is required.", }, workflowsHome: { - path: '', + path: "", exists: false, readable: false, writable: false, sizeBytes: null, - error: 'Local runtime access is required.', + error: "Local runtime access is required.", }, workflowsRepo: { - path: '', + path: "", exists: false, readable: false, writable: false, sizeBytes: null, - error: 'Local runtime access is required.', + error: "Local runtime access is required.", }, }, }); - renderWithQueryClient(React.createElement(SettingsPage)); + renderWithQueryClient(React.createElement(RuntimeSettingsPage)); await waitFor(() => { expect(configurationApi.getSourceStatus).toHaveBeenCalled(); @@ -424,13 +394,8 @@ describe('SettingsPage', () => { }); expect( - await screen.findByRole('tab', { name: 'Console preferences' }), + await screen.findByText("Local runtime access is unavailable.") ).toBeTruthy(); - fireEvent.click(screen.getByRole('tab', { name: 'Console preferences' })); - expect( - screen.queryByRole('tab', { name: 'Runtime configuration' }), - ).toBeNull(); - expect(screen.queryByText('Runtime configuration workspace')).toBeNull(); expect(configurationApi.listWorkflows).not.toHaveBeenCalled(); expect(configurationApi.getWorkflow).not.toHaveBeenCalled(); expect(configurationApi.getLlmDefault).not.toHaveBeenCalled(); @@ -444,23 +409,5 @@ describe('SettingsPage', () => { expect(configurationApi.listMcpServers).not.toHaveBeenCalled(); expect(configurationApi.getMcpRaw).not.toHaveBeenCalled(); expect(configurationApi.getSecretsRaw).not.toHaveBeenCalled(); - expect( - screen.getByText( - 'Local runtime configuration is hidden because this console is not connected through a loopback tool host.', - ), - ).toBeTruthy(); - }); - - it('surfaces Studio appearance in console preferences', async () => { - renderWithQueryClient(React.createElement(SettingsPage)); - - const preferencesTab = await screen.findByRole('tab', { - name: 'Console preferences', - }); - fireEvent.click(preferencesTab); - - expect(await screen.findByText('Studio appearance')).toBeTruthy(); - expect(screen.getByText('Studio accent')).toBeTruthy(); - expect(screen.getByText('Studio color mode')).toBeTruthy(); }); }); diff --git a/apps/aevatar-console-web/src/pages/settings/runtime.tsx b/apps/aevatar-console-web/src/pages/settings/runtime.tsx new file mode 100644 index 00000000..c1263f0b --- /dev/null +++ b/apps/aevatar-console-web/src/pages/settings/runtime.tsx @@ -0,0 +1,3 @@ +import RuntimeSettingsPage from "./runtimeSettingsPage"; + +export default RuntimeSettingsPage; diff --git a/apps/aevatar-console-web/src/pages/settings/runtimeSettingsPage.tsx b/apps/aevatar-console-web/src/pages/settings/runtimeSettingsPage.tsx new file mode 100644 index 00000000..c734b763 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/settings/runtimeSettingsPage.tsx @@ -0,0 +1,1839 @@ +import { + PageContainer, + ProCard, + ProDescriptions, +} from "@ant-design/pro-components"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import { Alert, Grid, message, Space, Tabs } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { configurationApi } from "@/shared/api/configurationApi"; +import { runtimeQueryApi } from "@/shared/api/runtimeQueryApi"; +import type { + ConfigurationEmbeddingsStatus, + ConfigurationLlmApiKeyStatus, + ConfigurationLlmProbeResult, + ConfigurationSecp256k1Status, + ConfigurationSecretValueStatus, + ConfigurationSkillsMpStatus, + ConfigurationWebSearchStatus, +} from "@/shared/models/platform/configuration"; +import { fillCardStyle, moduleCardProps } from "@/shared/ui/proComponents"; +import { + ConnectorsSection, + EmbeddingsSection, + LlmProvidersSection, + McpSection, + RawConfigSection, + SecretsSection, + SignerKeySection, + SkillsMpSection, + SystemStatusSection, + WebSearchSection, + WorkflowFilesSection, +} from "./runtimeSettingsWorkspaceSections"; +import { + type ConfigurationPathRecord, + type SettingsSummaryRecord, + type WorkflowDraftSource, + buildNewWorkflowTemplate, + formatMcpArgs, + formatMcpEnv, + formatProbeSummary, + normalizeWorkflowSource, + parseMcpArgs, + parseMcpEnv, + settingsSummaryColumns, + workflowKey, +} from "./runtimeSettingsShared"; + +const RuntimeSettingsPage: React.FC = () => { + const [messageApi, messageContextHolder] = message.useMessage(); + const queryClient = useQueryClient(); + const screens = Grid.useBreakpoint(); + const [selectedWorkflowKey, setSelectedWorkflowKey] = useState( + null + ); + const [selectedLlmInstanceName, setSelectedLlmInstanceName] = useState< + string | null + >(null); + const [isNewLlmInstanceDraft, setIsNewLlmInstanceDraft] = useState(false); + const [workflowFilename, setWorkflowFilename] = useState(""); + const [workflowSource, setWorkflowSource] = + useState("home"); + const [workflowContent, setWorkflowContent] = useState(""); + const [llmInstanceNameDraft, setLlmInstanceNameDraft] = useState(""); + const [llmProviderTypeDraft, setLlmProviderTypeDraft] = useState(""); + const [llmModelDraft, setLlmModelDraft] = useState(""); + const [llmEndpointDraft, setLlmEndpointDraft] = useState(""); + const [llmApiKeyDraft, setLlmApiKeyDraft] = useState(""); + const [llmProbeResult, setLlmProbeResult] = + useState(null); + const [llmModelsResult, setLlmModelsResult] = + useState(null); + const [embeddingsEnabledDraft, setEmbeddingsEnabledDraft] = useState(true); + const [embeddingsProviderTypeDraft, setEmbeddingsProviderTypeDraft] = + useState("deepseek"); + const [embeddingsEndpointDraft, setEmbeddingsEndpointDraft] = useState( + "https://dashscope.aliyuncs.com/compatible-mode/v1" + ); + const [embeddingsModelDraft, setEmbeddingsModelDraft] = + useState("text-embedding-v3"); + const [embeddingsApiKeyDraft, setEmbeddingsApiKeyDraft] = useState(""); + const [webSearchEnabledDraft, setWebSearchEnabledDraft] = useState(true); + const [webSearchProviderDraft, setWebSearchProviderDraft] = + useState("tavily"); + const [webSearchEndpointDraft, setWebSearchEndpointDraft] = useState(""); + const [webSearchTimeoutDraft, setWebSearchTimeoutDraft] = useState("15000"); + const [webSearchDepthDraft, setWebSearchDepthDraft] = useState("advanced"); + const [webSearchApiKeyDraft, setWebSearchApiKeyDraft] = useState(""); + const [skillsMpBaseUrlDraft, setSkillsMpBaseUrlDraft] = useState( + "https://skillsmp.com" + ); + const [skillsMpApiKeyDraft, setSkillsMpApiKeyDraft] = useState(""); + const [selectedMcpName, setSelectedMcpName] = useState(null); + const [isNewMcpDraft, setIsNewMcpDraft] = useState(false); + const [mcpNameDraft, setMcpNameDraft] = useState(""); + const [mcpCommandDraft, setMcpCommandDraft] = useState(""); + const [mcpArgsDraft, setMcpArgsDraft] = useState(""); + const [mcpEnvDraft, setMcpEnvDraft] = useState("{}"); + const [mcpTimeoutDraft, setMcpTimeoutDraft] = useState("60000"); + const [configJsonDraft, setConfigJsonDraft] = useState(""); + const [connectorsJsonDraft, setConnectorsJsonDraft] = useState(""); + const [mcpJsonDraft, setMcpJsonDraft] = useState(""); + const [secretKeyDraft, setSecretKeyDraft] = useState(""); + const [secretValueDraft, setSecretValueDraft] = useState(""); + const [secretsJsonDraft, setSecretsJsonDraft] = useState(""); + const [pendingDefaultProvider, setPendingDefaultProvider] = useState(""); + const runtimeSummaryEnabled = true; + + const capabilitiesQuery = useQuery({ + queryKey: ["settings-capabilities"], + queryFn: () => runtimeQueryApi.getCapabilities(), + enabled: runtimeSummaryEnabled, + }); + const configurationHealthQuery = useQuery({ + queryKey: ["settings-configuration-health"], + queryFn: () => configurationApi.getHealth(), + enabled: runtimeSummaryEnabled, + retry: false, + }); + const configurationSourceQuery = useQuery({ + queryKey: ["settings-configuration-source"], + queryFn: () => configurationApi.getSourceStatus(), + enabled: runtimeSummaryEnabled, + retry: false, + }); + const hasLocalRuntimeAccess = + configurationSourceQuery.data?.localRuntimeAccess ?? false; + const runtimeWorkspaceEnabled = hasLocalRuntimeAccess; + const configurationWorkflowsQuery = useQuery({ + queryKey: ["settings-configuration-workflows"], + queryFn: () => configurationApi.listWorkflows("all"), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationLlmProvidersQuery = useQuery({ + queryKey: ["settings-configuration-llm-providers"], + queryFn: () => configurationApi.listLlmProviders(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationLlmInstancesQuery = useQuery({ + queryKey: ["settings-configuration-llm-instances"], + queryFn: () => configurationApi.listLlmInstances(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationLlmDefaultQuery = useQuery({ + queryKey: ["settings-configuration-llm-default"], + queryFn: () => configurationApi.getLlmDefault(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationEmbeddingsQuery = useQuery({ + queryKey: ["settings-configuration-embeddings"], + queryFn: () => configurationApi.getEmbeddingsStatus(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationWebSearchQuery = useQuery({ + queryKey: ["settings-configuration-websearch"], + queryFn: () => configurationApi.getWebSearchStatus(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationSkillsMpQuery = useQuery({ + queryKey: ["settings-configuration-skillsmp"], + queryFn: () => configurationApi.getSkillsMpStatus(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationSecp256k1Query = useQuery({ + queryKey: ["settings-configuration-secp256k1"], + queryFn: () => configurationApi.getSecp256k1Status(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationConfigRawQuery = useQuery({ + queryKey: ["settings-configuration-config-raw"], + queryFn: () => configurationApi.getConfigRaw(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationConnectorsRawQuery = useQuery({ + queryKey: ["settings-configuration-connectors-raw"], + queryFn: () => configurationApi.getConnectorsRaw(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationMcpServersQuery = useQuery({ + queryKey: ["settings-configuration-mcp-servers"], + queryFn: () => configurationApi.listMcpServers(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationMcpRawQuery = useQuery({ + queryKey: ["settings-configuration-mcp-raw"], + queryFn: () => configurationApi.getMcpRaw(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + const configurationSecretsRawQuery = useQuery({ + queryKey: ["settings-configuration-secrets-raw"], + queryFn: () => configurationApi.getSecretsRaw(), + enabled: runtimeWorkspaceEnabled, + retry: false, + }); + + const selectedWorkflow = useMemo(() => { + const items = configurationWorkflowsQuery.data ?? []; + if (!selectedWorkflowKey) { + return items[0] ?? null; + } + + return ( + items.find((item) => workflowKey(item) === selectedWorkflowKey) ?? + items[0] ?? + null + ); + }, [configurationWorkflowsQuery.data, selectedWorkflowKey]); + + const selectedLlmInstance = useMemo(() => { + const items = configurationLlmInstancesQuery.data ?? []; + if (isNewLlmInstanceDraft) { + return null; + } + + if (!selectedLlmInstanceName) { + return items[0] ?? null; + } + + return ( + items.find((item) => item.name === selectedLlmInstanceName) ?? + items[0] ?? + null + ); + }, [ + configurationLlmInstancesQuery.data, + isNewLlmInstanceDraft, + selectedLlmInstanceName, + ]); + + const selectedMcpServer = useMemo(() => { + const items = configurationMcpServersQuery.data ?? []; + if (isNewMcpDraft) { + return null; + } + + if (!selectedMcpName) { + return items[0] ?? null; + } + + return ( + items.find((item) => item.name === selectedMcpName) ?? items[0] ?? null + ); + }, [configurationMcpServersQuery.data, isNewMcpDraft, selectedMcpName]); + + const workflowDetailQuery = useQuery({ + queryKey: [ + "settings-configuration-workflow-detail", + selectedWorkflow?.filename, + selectedWorkflow?.source, + ], + queryFn: () => { + if (!selectedWorkflow) { + throw new Error("No workflow is selected."); + } + + return configurationApi.getWorkflow( + selectedWorkflow.filename, + normalizeWorkflowSource(selectedWorkflow.source) + ); + }, + enabled: runtimeWorkspaceEnabled && Boolean(selectedWorkflow), + retry: false, + }); + + const configurationLlmApiKeyQuery = useQuery({ + queryKey: ["settings-configuration-llm-api-key", selectedLlmInstance?.name], + queryFn: () => { + if (!selectedLlmInstance?.name) { + throw new Error("No LLM instance is selected."); + } + + return configurationApi.getLlmApiKey(selectedLlmInstance.name); + }, + enabled: runtimeWorkspaceEnabled && Boolean(selectedLlmInstance?.name), + retry: false, + }); + + useEffect(() => { + const items = configurationWorkflowsQuery.data ?? []; + if (items.length === 0) { + return; + } + + if (!selectedWorkflowKey) { + setSelectedWorkflowKey(workflowKey(items[0])); + return; + } + + if (!items.some((item) => workflowKey(item) === selectedWorkflowKey)) { + setSelectedWorkflowKey(workflowKey(items[0])); + } + }, [configurationWorkflowsQuery.data, selectedWorkflowKey]); + + useEffect(() => { + const items = configurationLlmInstancesQuery.data ?? []; + if (items.length === 0) { + return; + } + + if (isNewLlmInstanceDraft) { + return; + } + + if (!selectedLlmInstanceName) { + setSelectedLlmInstanceName(items[0].name); + return; + } + + if (!items.some((item) => item.name === selectedLlmInstanceName)) { + setSelectedLlmInstanceName(items[0].name); + } + }, [ + configurationLlmInstancesQuery.data, + isNewLlmInstanceDraft, + selectedLlmInstanceName, + ]); + + useEffect(() => { + const items = configurationMcpServersQuery.data ?? []; + if (items.length === 0) { + return; + } + + if (isNewMcpDraft) { + return; + } + + if (!selectedMcpName) { + setSelectedMcpName(items[0].name); + return; + } + + if (!items.some((item) => item.name === selectedMcpName)) { + setSelectedMcpName(items[0].name); + } + }, [configurationMcpServersQuery.data, isNewMcpDraft, selectedMcpName]); + + useEffect(() => { + if (!workflowDetailQuery.data) { + return; + } + + setWorkflowFilename(workflowDetailQuery.data.filename); + setWorkflowSource(normalizeWorkflowSource(workflowDetailQuery.data.source)); + setWorkflowContent(workflowDetailQuery.data.content); + }, [workflowDetailQuery.data]); + + useEffect(() => { + if (!selectedLlmInstance) { + return; + } + + setLlmInstanceNameDraft(selectedLlmInstance.name); + setLlmProviderTypeDraft(selectedLlmInstance.providerType); + setLlmModelDraft(selectedLlmInstance.model); + setLlmEndpointDraft(selectedLlmInstance.endpoint); + setLlmApiKeyDraft(""); + setLlmProbeResult(null); + setLlmModelsResult(null); + }, [selectedLlmInstance]); + + useEffect(() => { + if (configurationConfigRawQuery.data) { + setConfigJsonDraft(configurationConfigRawQuery.data.json); + } + }, [configurationConfigRawQuery.data]); + + useEffect(() => { + if (!configurationEmbeddingsQuery.data) { + return; + } + + setEmbeddingsEnabledDraft( + configurationEmbeddingsQuery.data.enabled ?? true + ); + setEmbeddingsProviderTypeDraft( + configurationEmbeddingsQuery.data.providerType || "deepseek" + ); + setEmbeddingsEndpointDraft( + configurationEmbeddingsQuery.data.endpoint || + "https://dashscope.aliyuncs.com/compatible-mode/v1" + ); + setEmbeddingsModelDraft( + configurationEmbeddingsQuery.data.model || "text-embedding-v3" + ); + setEmbeddingsApiKeyDraft(""); + }, [configurationEmbeddingsQuery.data]); + + useEffect(() => { + if (!configurationWebSearchQuery.data) { + return; + } + + setWebSearchEnabledDraft(configurationWebSearchQuery.data.enabled ?? true); + setWebSearchProviderDraft( + configurationWebSearchQuery.data.provider || "tavily" + ); + setWebSearchEndpointDraft(configurationWebSearchQuery.data.endpoint || ""); + setWebSearchTimeoutDraft( + configurationWebSearchQuery.data.timeoutMs != null + ? String(configurationWebSearchQuery.data.timeoutMs) + : "15000" + ); + setWebSearchDepthDraft( + configurationWebSearchQuery.data.searchDepth || "advanced" + ); + setWebSearchApiKeyDraft(""); + }, [configurationWebSearchQuery.data]); + + useEffect(() => { + if (!configurationSkillsMpQuery.data) { + return; + } + + setSkillsMpBaseUrlDraft( + configurationSkillsMpQuery.data.baseUrl || "https://skillsmp.com" + ); + setSkillsMpApiKeyDraft(""); + }, [configurationSkillsMpQuery.data]); + + useEffect(() => { + if (!selectedMcpServer) { + return; + } + + setMcpNameDraft(selectedMcpServer.name); + setMcpCommandDraft(selectedMcpServer.command); + setMcpArgsDraft(formatMcpArgs(selectedMcpServer.args)); + setMcpEnvDraft(formatMcpEnv(selectedMcpServer.env)); + setMcpTimeoutDraft(String(selectedMcpServer.timeoutMs)); + }, [selectedMcpServer]); + + useEffect(() => { + if (configurationConnectorsRawQuery.data) { + setConnectorsJsonDraft(configurationConnectorsRawQuery.data.json); + } + }, [configurationConnectorsRawQuery.data]); + + useEffect(() => { + if (configurationMcpRawQuery.data) { + setMcpJsonDraft(configurationMcpRawQuery.data.json); + } + }, [configurationMcpRawQuery.data]); + + useEffect(() => { + if (configurationSecretsRawQuery.data) { + setSecretsJsonDraft(configurationSecretsRawQuery.data.json); + } + }, [configurationSecretsRawQuery.data]); + + useEffect(() => { + if (configurationLlmDefaultQuery.data) { + setPendingDefaultProvider(configurationLlmDefaultQuery.data); + } + }, [configurationLlmDefaultQuery.data]); + + const settingsSummary = useMemo( + () => ({ + configStatus: + configurationHealthQuery.isSuccess && hasLocalRuntimeAccess + ? "ready" + : "unavailable", + configMode: hasLocalRuntimeAccess + ? configurationSourceQuery.data?.mode ?? "" + : "restricted", + runtimeWorkflowFiles: hasLocalRuntimeAccess + ? configurationWorkflowsQuery.data?.length ?? 0 + : 0, + primitiveCount: capabilitiesQuery.data?.primitives.length ?? 0, + defaultProvider: hasLocalRuntimeAccess + ? configurationLlmDefaultQuery.data ?? "" + : "", + }), + [ + capabilitiesQuery.data?.primitives.length, + configurationHealthQuery.isSuccess, + configurationLlmDefaultQuery.data, + hasLocalRuntimeAccess, + configurationSourceQuery.data?.mode, + configurationWorkflowsQuery.data?.length, + ] + ); + + const configurationPathRecords = useMemo(() => { + const doctor = configurationSourceQuery.data?.doctor; + if (!doctor) { + return []; + } + + return [ + { id: "config", label: "config.json", status: doctor.config }, + { id: "secrets", label: "secrets.json", status: doctor.secrets }, + { + id: "workflows-home", + label: "workflows (home)", + status: doctor.workflowsHome, + }, + { + id: "workflows-repo", + label: "workflows (repo)", + status: doctor.workflowsRepo, + }, + { id: "connectors", label: "connectors.json", status: doctor.connectors }, + { id: "mcp", label: "mcp.json", status: doctor.mcp }, + ]; + }, [configurationSourceQuery.data]); + + const saveWorkflowMutation = useMutation({ + mutationFn: () => + configurationApi.saveWorkflow({ + filename: workflowFilename, + content: workflowContent, + source: workflowSource, + }), + onSuccess: async (saved) => { + messageApi.success(`Saved ${saved.filename} to ${saved.source}.`); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-workflows"], + }), + queryClient.invalidateQueries({ + queryKey: [ + "settings-configuration-workflow-detail", + saved.filename, + saved.source, + ], + }), + ]); + setSelectedWorkflowKey(workflowKey(saved)); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to save workflow." + ); + }, + }); + + const deleteWorkflowMutation = useMutation({ + mutationFn: async () => { + await configurationApi.deleteWorkflow({ + filename: workflowFilename, + source: workflowSource, + }); + }, + onSuccess: async () => { + messageApi.success(`Deleted ${workflowFilename}.`); + setSelectedWorkflowKey(null); + setWorkflowFilename(""); + setWorkflowContent(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-workflows"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-workflow-detail"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to delete workflow." + ); + }, + }); + + const saveConfigRawMutation = useMutation({ + mutationFn: () => configurationApi.saveConfigRaw(configJsonDraft), + onSuccess: async () => { + messageApi.success("config.json saved."); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-config-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-websearch"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-skillsmp"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to save config.json." + ); + }, + }); + + const saveConnectorsRawMutation = useMutation({ + mutationFn: () => configurationApi.saveConnectorsRaw(connectorsJsonDraft), + onSuccess: async () => { + messageApi.success("connectors.json saved."); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-connectors-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to save connectors.json." + ); + }, + }); + + const validateConnectorsRawMutation = useMutation({ + mutationFn: () => + configurationApi.validateConnectorsRaw(connectorsJsonDraft), + onSuccess: (result) => { + messageApi.success(`connectors.json is valid (${result.count} entries).`); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to validate connectors.json." + ); + }, + }); + + const saveMcpRawMutation = useMutation({ + mutationFn: () => configurationApi.saveMcpRaw(mcpJsonDraft), + onSuccess: async () => { + messageApi.success("mcp.json saved."); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-mcp-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to save mcp.json." + ); + }, + }); + + const saveMcpServerMutation = useMutation({ + mutationFn: () => { + const timeoutMs = Number.parseInt(mcpTimeoutDraft.trim(), 10); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error("MCP timeout must be a positive integer."); + } + + return configurationApi.saveMcpServer({ + name: mcpNameDraft.trim(), + command: mcpCommandDraft.trim(), + args: parseMcpArgs(mcpArgsDraft), + env: parseMcpEnv(mcpEnvDraft), + timeoutMs, + }); + }, + onSuccess: async (server) => { + messageApi.success(`Saved MCP server ${server.name}.`); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-mcp-servers"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-mcp-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + setIsNewMcpDraft(false); + setSelectedMcpName(server.name); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to save MCP server." + ); + }, + }); + + const validateMcpRawMutation = useMutation({ + mutationFn: () => configurationApi.validateMcpRaw(mcpJsonDraft), + onSuccess: (result) => { + messageApi.success(`mcp.json is valid (${result.count} servers).`); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to validate mcp.json." + ); + }, + }); + + const deleteMcpServerMutation = useMutation({ + mutationFn: async () => { + await configurationApi.deleteMcpServer(mcpNameDraft.trim()); + }, + onSuccess: async () => { + messageApi.success(`Deleted MCP server ${mcpNameDraft.trim()}.`); + setIsNewMcpDraft(false); + setSelectedMcpName(null); + setMcpNameDraft(""); + setMcpCommandDraft(""); + setMcpArgsDraft(""); + setMcpEnvDraft("{}"); + setMcpTimeoutDraft("60000"); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-mcp-servers"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-mcp-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to delete MCP server." + ); + }, + }); + + const saveSecretsRawMutation = useMutation({ + mutationFn: () => configurationApi.saveSecretsRaw(secretsJsonDraft), + onSuccess: async () => { + messageApi.success("secrets.json saved."); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-api-key"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-embeddings"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-websearch"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-skillsmp"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secp256k1"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-providers"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-instances"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-default"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to save secrets.json." + ); + }, + }); + + const saveLlmInstanceMutation = useMutation({ + mutationFn: () => + configurationApi.saveLlmInstance({ + providerName: llmInstanceNameDraft.trim(), + providerType: llmProviderTypeDraft.trim(), + model: llmModelDraft.trim(), + endpoint: llmEndpointDraft.trim() || undefined, + apiKey: llmApiKeyDraft.trim() || undefined, + }), + onSuccess: async () => { + const name = llmInstanceNameDraft.trim(); + messageApi.success(`Saved LLM instance ${name}.`); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-instances"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-providers"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-default"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-api-key", name], + }), + ]); + setIsNewLlmInstanceDraft(false); + setSelectedLlmInstanceName(name); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to save LLM instance." + ); + }, + }); + + const deleteLlmInstanceMutation = useMutation({ + mutationFn: () => + configurationApi.deleteLlmInstance(llmInstanceNameDraft.trim()), + onSuccess: async () => { + const name = llmInstanceNameDraft.trim(); + messageApi.success(`Deleted LLM instance ${name}.`); + setIsNewLlmInstanceDraft(false); + setSelectedLlmInstanceName(null); + setLlmInstanceNameDraft(""); + setLlmProviderTypeDraft(""); + setLlmModelDraft(""); + setLlmEndpointDraft(""); + setLlmApiKeyDraft(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-instances"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-providers"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-default"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-api-key", name], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to delete LLM instance." + ); + }, + }); + + const setLlmApiKeyMutation = useMutation({ + mutationFn: () => + configurationApi.setLlmApiKey({ + providerName: llmInstanceNameDraft.trim(), + apiKey: llmApiKeyDraft.trim(), + }), + onSuccess: async () => { + const name = llmInstanceNameDraft.trim(); + messageApi.success(`API key updated for ${name}.`); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-api-key", name], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-default"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-instances"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to update API key." + ); + }, + }); + + const deleteLlmApiKeyMutation = useMutation({ + mutationFn: () => + configurationApi.deleteLlmApiKey(llmInstanceNameDraft.trim()), + onSuccess: async () => { + const name = llmInstanceNameDraft.trim(); + messageApi.success(`API key removed for ${name}.`); + setLlmApiKeyDraft(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-api-key", name], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-default"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-instances"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to remove API key." + ); + }, + }); + + const revealLlmApiKeyMutation = useMutation({ + mutationFn: () => + configurationApi.getLlmApiKey(llmInstanceNameDraft.trim(), { + reveal: true, + }), + onSuccess: (result) => { + setLlmApiKeyDraft(result.value ?? ""); + messageApi.success(`Loaded API key for ${result.providerName}.`); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to reveal API key." + ); + }, + }); + + const saveEmbeddingsMutation = useMutation({ + mutationFn: () => + configurationApi.saveEmbeddings({ + enabled: embeddingsEnabledDraft, + providerType: embeddingsProviderTypeDraft.trim(), + endpoint: embeddingsEndpointDraft.trim(), + model: embeddingsModelDraft.trim(), + apiKey: embeddingsApiKeyDraft.trim() || undefined, + }), + onSuccess: async () => { + messageApi.success("Embeddings configuration saved."); + setEmbeddingsApiKeyDraft(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-embeddings"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to save embeddings configuration." + ); + }, + }); + + const deleteEmbeddingsMutation = useMutation({ + mutationFn: () => configurationApi.deleteEmbeddings(), + onSuccess: async () => { + messageApi.success("Embeddings configuration deleted."); + setEmbeddingsApiKeyDraft(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-embeddings"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to delete embeddings configuration." + ); + }, + }); + + const revealEmbeddingsApiKeyMutation = + useMutation({ + mutationFn: () => configurationApi.getEmbeddingsApiKey({ reveal: true }), + onSuccess: (result) => { + setEmbeddingsApiKeyDraft(result.value ?? ""); + messageApi.success("Loaded embeddings API key."); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to reveal embeddings API key." + ); + }, + }); + + const saveWebSearchMutation = useMutation({ + mutationFn: () => { + const timeoutMs = webSearchTimeoutDraft.trim() + ? Number.parseInt(webSearchTimeoutDraft.trim(), 10) + : undefined; + if ( + webSearchTimeoutDraft.trim() && + (!Number.isFinite(timeoutMs) || Number(timeoutMs) <= 0) + ) { + throw new Error("Web search timeout must be a positive integer."); + } + + return configurationApi.saveWebSearch({ + enabled: webSearchEnabledDraft, + provider: webSearchProviderDraft.trim(), + endpoint: webSearchEndpointDraft.trim() || undefined, + timeoutMs, + searchDepth: webSearchDepthDraft.trim() || undefined, + apiKey: webSearchApiKeyDraft.trim() || undefined, + }); + }, + onSuccess: async () => { + messageApi.success("Web search configuration saved."); + setWebSearchApiKeyDraft(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-websearch"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-config-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to save web search configuration." + ); + }, + }); + + const deleteWebSearchMutation = useMutation({ + mutationFn: () => configurationApi.deleteWebSearch(), + onSuccess: async () => { + messageApi.success("Web search configuration deleted."); + setWebSearchApiKeyDraft(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-websearch"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-config-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to delete web search configuration." + ); + }, + }); + + const revealWebSearchApiKeyMutation = + useMutation({ + mutationFn: () => configurationApi.getWebSearchApiKey({ reveal: true }), + onSuccess: (result) => { + setWebSearchApiKeyDraft(result.value ?? ""); + messageApi.success("Loaded web search API key."); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to reveal web search API key." + ); + }, + }); + + const saveSkillsMpMutation = useMutation({ + mutationFn: () => + configurationApi.saveSkillsMp({ + apiKey: skillsMpApiKeyDraft.trim() || undefined, + baseUrl: skillsMpBaseUrlDraft.trim() || undefined, + }), + onSuccess: async () => { + messageApi.success("SkillsMP configuration saved."); + setSkillsMpApiKeyDraft(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-skillsmp"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-config-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to save SkillsMP configuration." + ); + }, + }); + + const deleteSkillsMpMutation = useMutation({ + mutationFn: () => configurationApi.deleteSkillsMp(), + onSuccess: async () => { + messageApi.success("SkillsMP configuration deleted."); + setSkillsMpApiKeyDraft(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-skillsmp"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-config-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to delete SkillsMP configuration." + ); + }, + }); + + const revealSkillsMpApiKeyMutation = + useMutation({ + mutationFn: () => configurationApi.getSkillsMpApiKey({ reveal: true }), + onSuccess: (result) => { + setSkillsMpApiKeyDraft(result.value ?? ""); + messageApi.success("Loaded SkillsMP API key."); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to reveal SkillsMP API key." + ); + }, + }); + + const generateSecp256k1Mutation = useMutation({ + mutationFn: () => configurationApi.generateSecp256k1(), + onSuccess: async (result) => { + messageApi.success( + result.backedUp + ? "Generated signer key and backed up the previous private key." + : "Generated signer key." + ); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secp256k1"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-source"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to generate signer key." + ); + }, + }); + + const probeLlmTestMutation = useMutation({ + mutationFn: () => + configurationApi.probeLlmTest({ + providerType: llmProviderTypeDraft.trim(), + endpoint: llmEndpointDraft.trim() || undefined, + apiKey: llmApiKeyDraft.trim(), + }), + onSuccess: (result) => { + setLlmProbeResult(result); + messageApi[result.ok ? "success" : "warning"](formatProbeSummary(result)); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to probe provider." + ); + }, + }); + + const probeLlmModelsMutation = useMutation({ + mutationFn: () => + configurationApi.probeLlmModels({ + providerType: llmProviderTypeDraft.trim(), + endpoint: llmEndpointDraft.trim() || undefined, + apiKey: llmApiKeyDraft.trim(), + }), + onSuccess: (result) => { + setLlmModelsResult(result); + messageApi[result.ok ? "success" : "warning"](formatProbeSummary(result)); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to fetch models." + ); + }, + }); + + const setLlmDefaultMutation = useMutation({ + mutationFn: () => configurationApi.setLlmDefault(pendingDefaultProvider), + onSuccess: async (providerName) => { + messageApi.success(`Default provider set to ${providerName}.`); + await queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-default"], + }); + }, + onError: (error) => { + messageApi.error( + error instanceof Error + ? error.message + : "Failed to update default provider." + ); + }, + }); + + const setSecretMutation = useMutation({ + mutationFn: () => + configurationApi.setSecret({ + key: secretKeyDraft.trim(), + value: secretValueDraft, + }), + onSuccess: async () => { + messageApi.success(`Secret ${secretKeyDraft.trim()} saved.`); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-api-key"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-embeddings"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-websearch"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-skillsmp"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secp256k1"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-providers"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-instances"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-default"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to save secret." + ); + }, + }); + + const removeSecretMutation = useMutation({ + mutationFn: () => configurationApi.removeSecret(secretKeyDraft.trim()), + onSuccess: async () => { + messageApi.success(`Secret ${secretKeyDraft.trim()} removed.`); + setSecretValueDraft(""); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secrets-raw"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-api-key"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-embeddings"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-websearch"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-skillsmp"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-secp256k1"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-providers"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-instances"], + }), + queryClient.invalidateQueries({ + queryKey: ["settings-configuration-llm-default"], + }), + ]); + }, + onError: (error) => { + messageApi.error( + error instanceof Error ? error.message : "Failed to remove secret." + ); + }, + }); + + const handleNewWorkflowDraft = () => { + const filename = "new_workflow.yaml"; + setSelectedWorkflowKey(null); + setWorkflowFilename(filename); + setWorkflowSource("home"); + setWorkflowContent(buildNewWorkflowTemplate(filename)); + }; + + const handleNewLlmInstanceDraft = () => { + setIsNewLlmInstanceDraft(true); + setSelectedLlmInstanceName(null); + setLlmInstanceNameDraft("new-provider"); + setLlmProviderTypeDraft(configurationLlmProvidersQuery.data?.[0]?.id ?? ""); + setLlmModelDraft(""); + setLlmEndpointDraft(""); + setLlmApiKeyDraft(""); + setLlmProbeResult(null); + setLlmModelsResult(null); + }; + + const handleNewMcpServerDraft = () => { + setIsNewMcpDraft(true); + setSelectedMcpName(null); + setMcpNameDraft("new-mcp-server"); + setMcpCommandDraft(""); + setMcpArgsDraft(""); + setMcpEnvDraft("{}"); + setMcpTimeoutDraft("60000"); + }; + + const handleDeleteWorkflow = () => { + if (!workflowFilename) { + messageApi.warning("Select a workflow file before deleting."); + return; + } + + if (!window.confirm(`Delete ${workflowFilename} from ${workflowSource}?`)) { + return; + } + + deleteWorkflowMutation.mutate(); + }; + + const handleDeleteLlmInstance = () => { + const name = llmInstanceNameDraft.trim(); + if (!name) { + messageApi.warning("Select an LLM instance before deleting."); + return; + } + + if (!window.confirm(`Delete LLM instance ${name}?`)) { + return; + } + + deleteLlmInstanceMutation.mutate(); + }; + + const handleDeleteMcpServer = () => { + const name = mcpNameDraft.trim(); + if (!name) { + messageApi.warning("Select an MCP server before deleting."); + return; + } + + if (!window.confirm(`Delete MCP server ${name}?`)) { + return; + } + + deleteMcpServerMutation.mutate(); + }; + + const handleDeleteEmbeddings = () => { + if ( + !window.confirm( + "Delete embeddings configuration, including the stored API key?" + ) + ) { + return; + } + + deleteEmbeddingsMutation.mutate(); + }; + + const handleDeleteWebSearch = () => { + if ( + !window.confirm( + "Delete web search configuration, including the stored API key?" + ) + ) { + return; + } + + deleteWebSearchMutation.mutate(); + }; + + const handleDeleteSkillsMp = () => { + if ( + !window.confirm( + "Delete SkillsMP configuration, including the stored API key?" + ) + ) { + return; + } + + deleteSkillsMpMutation.mutate(); + }; + + const handleGenerateSecp256k1 = () => { + if ( + !window.confirm( + "Generate a new secp256k1 private key and save it locally? Existing material will be backed up automatically." + ) + ) { + return; + } + + generateSecp256k1Mutation.mutate(); + }; + + const llmDefaultOptions = useMemo( + () => + (configurationLlmInstancesQuery.data ?? []).map((item) => ({ + label: `${item.name} · ${item.providerDisplayName}`, + value: item.name, + })), + [configurationLlmInstancesQuery.data] + ); + + const llmProviderTypeOptions = useMemo( + () => + (configurationLlmProvidersQuery.data ?? []).map((item) => ({ + label: `${item.displayName} · ${item.category}`, + value: item.id, + })), + [configurationLlmProvidersQuery.data] + ); + + const workspaceTabs = [ + { + key: "system", + label: "System status", + children: ( + + ), + }, + { + key: "workflows", + label: "Workflow files", + children: ( + { + void configurationWorkflowsQuery.refetch(); + }} + onSelectWorkflow={setSelectedWorkflowKey} + onWorkflowFilenameChange={setWorkflowFilename} + onWorkflowSourceChange={setWorkflowSource} + onWorkflowContentChange={setWorkflowContent} + onNewDraft={handleNewWorkflowDraft} + onReloadSelectedWorkflow={() => { + void workflowDetailQuery.refetch(); + }} + onSaveWorkflow={() => saveWorkflowMutation.mutate()} + onDeleteWorkflow={handleDeleteWorkflow} + /> + ), + }, + { + key: "embeddings", + label: "Embeddings", + children: ( + { + void configurationEmbeddingsQuery.refetch(); + }} + onEnabledChange={setEmbeddingsEnabledDraft} + onProviderTypeChange={setEmbeddingsProviderTypeDraft} + onEndpointChange={setEmbeddingsEndpointDraft} + onModelChange={setEmbeddingsModelDraft} + onApiKeyChange={setEmbeddingsApiKeyDraft} + onRevealApiKey={() => revealEmbeddingsApiKeyMutation.mutate()} + onSave={() => saveEmbeddingsMutation.mutate()} + onDelete={handleDeleteEmbeddings} + /> + ), + }, + { + key: "websearch", + label: "Web Search", + children: ( + { + void configurationWebSearchQuery.refetch(); + }} + onEnabledChange={setWebSearchEnabledDraft} + onProviderChange={setWebSearchProviderDraft} + onEndpointChange={setWebSearchEndpointDraft} + onTimeoutChange={setWebSearchTimeoutDraft} + onSearchDepthChange={setWebSearchDepthDraft} + onApiKeyChange={setWebSearchApiKeyDraft} + onRevealApiKey={() => revealWebSearchApiKeyMutation.mutate()} + onSave={() => saveWebSearchMutation.mutate()} + onDelete={handleDeleteWebSearch} + /> + ), + }, + { + key: "skillsmp", + label: "SkillsMP", + children: ( + { + void configurationSkillsMpQuery.refetch(); + }} + onBaseUrlChange={setSkillsMpBaseUrlDraft} + onApiKeyChange={setSkillsMpApiKeyDraft} + onRevealApiKey={() => revealSkillsMpApiKeyMutation.mutate()} + onSave={() => saveSkillsMpMutation.mutate()} + onDelete={handleDeleteSkillsMp} + /> + ), + }, + { + key: "signer-key", + label: "Signer key", + children: ( + { + void configurationSecp256k1Query.refetch(); + }} + onGenerate={handleGenerateSecp256k1} + /> + ), + }, + { + key: "connectors", + label: "Connectors", + children: ( + { + void configurationConnectorsRawQuery.refetch(); + }} + onValidate={() => validateConnectorsRawMutation.mutate()} + onSave={() => saveConnectorsRawMutation.mutate()} + onConnectorsJsonChange={setConnectorsJsonDraft} + /> + ), + }, + { + key: "llm", + label: "LLM providers", + children: ( + setLlmDefaultMutation.mutate()} + onNewInstance={handleNewLlmInstanceDraft} + onLlmInstanceNameChange={setLlmInstanceNameDraft} + onLlmProviderTypeChange={setLlmProviderTypeDraft} + onLlmModelChange={setLlmModelDraft} + onLlmEndpointChange={setLlmEndpointDraft} + onLlmApiKeyChange={setLlmApiKeyDraft} + onSaveInstance={() => saveLlmInstanceMutation.mutate()} + onDeleteInstance={handleDeleteLlmInstance} + onRevealApiKey={() => revealLlmApiKeyMutation.mutate()} + onSetApiKey={() => setLlmApiKeyMutation.mutate()} + onDeleteApiKey={() => deleteLlmApiKeyMutation.mutate()} + onTestConnection={() => probeLlmTestMutation.mutate()} + onFetchModels={() => probeLlmModelsMutation.mutate()} + onSelectInstance={(name) => { + setIsNewLlmInstanceDraft(false); + setSelectedLlmInstanceName(name); + }} + /> + ), + }, + { + key: "mcp", + label: "MCP", + children: ( + { + void configurationMcpServersQuery.refetch(); + }} + onSelectServer={(name) => { + setIsNewMcpDraft(false); + setSelectedMcpName(name); + }} + onMcpNameChange={setMcpNameDraft} + onMcpCommandChange={setMcpCommandDraft} + onMcpArgsChange={setMcpArgsDraft} + onMcpEnvChange={setMcpEnvDraft} + onMcpTimeoutChange={setMcpTimeoutDraft} + onMcpJsonChange={setMcpJsonDraft} + onNewServer={handleNewMcpServerDraft} + onSaveServer={() => saveMcpServerMutation.mutate()} + onDeleteServer={handleDeleteMcpServer} + onReloadRaw={() => { + void configurationMcpRawQuery.refetch(); + }} + onValidateRaw={() => validateMcpRawMutation.mutate()} + onSaveRaw={() => saveMcpRawMutation.mutate()} + /> + ), + }, + { + key: "secrets", + label: "Secrets", + children: ( + { + void configurationSecretsRawQuery.refetch(); + }} + onSecretsJsonChange={setSecretsJsonDraft} + onSecretKeyChange={setSecretKeyDraft} + onSecretValueChange={setSecretValueDraft} + onSetSecret={() => setSecretMutation.mutate()} + onRemoveSecret={() => removeSecretMutation.mutate()} + onSaveRaw={() => saveSecretsRawMutation.mutate()} + /> + ), + }, + { + key: "config", + label: "Raw config", + children: ( + { + void configurationConfigRawQuery.refetch(); + }} + onConfigJsonChange={setConfigJsonDraft} + onSave={() => saveConfigRawMutation.mutate()} + /> + ), + }, + ]; + + const summaryColumnCount = screens.xxl + ? 4 + : screens.lg + ? 3 + : screens.md + ? 2 + : 1; + const workspaceTabPlacement = screens.xl ? "start" : "top"; + const runtimeWorkspaceContent = ( + + {hasLocalRuntimeAccess ? ( + + ) : ( + + )} + + ); + + return ( + history.push("/settings/console")} + > + {messageContextHolder} + + + + column={summaryColumnCount} + dataSource={settingsSummary} + columns={settingsSummaryColumns} + /> + + {runtimeWorkspaceContent} + + + ); +}; + +export default RuntimeSettingsPage; diff --git a/apps/aevatar-console-web/src/pages/settings/runtimeSettingsShared.tsx b/apps/aevatar-console-web/src/pages/settings/runtimeSettingsShared.tsx new file mode 100644 index 00000000..fb976349 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/settings/runtimeSettingsShared.tsx @@ -0,0 +1,135 @@ +import type { ProDescriptionsItemProps } from "@ant-design/pro-components"; +import { Tag } from "antd"; +import React from "react"; +import type { + ConfigurationLlmProbeResult, + ConfigurationPathStatus, + ConfigurationWorkflowFile, +} from "@/shared/models/platform/configuration"; + +export type SettingsSummaryRecord = { + configStatus: "ready" | "unavailable"; + configMode: string; + runtimeWorkflowFiles: number; + primitiveCount: number; + defaultProvider: string; +}; + +export type ConfigurationPathRecord = { + id: string; + label: string; + status: ConfigurationPathStatus; +}; + +export type WorkflowDraftSource = "home" | "repo"; + +const configurationValueEnum = { + ready: { text: "Ready", status: "Success" }, + unavailable: { text: "Unavailable", status: "Error" }, +} as const; + +export const settingsSummaryColumns: ProDescriptionsItemProps[] = + [ + { + title: "Configuration API", + dataIndex: "configStatus", + valueType: "status" as any, + valueEnum: configurationValueEnum, + }, + { + title: "Configuration mode", + dataIndex: "configMode", + render: (_, record) => record.configMode || "unknown", + }, + { + title: "Runtime workflow files", + dataIndex: "runtimeWorkflowFiles", + valueType: "digit", + }, + { + title: "Primitive count", + dataIndex: "primitiveCount", + valueType: "digit", + }, + { + title: "Default provider", + dataIndex: "defaultProvider", + render: (_, record) => {record.defaultProvider || "default"}, + }, + ]; + +export function workflowKey( + item: Pick +): string { + return `${item.source}:${item.filename}`; +} + +export function normalizeWorkflowSource(source: string): WorkflowDraftSource { + return source === "repo" ? "repo" : "home"; +} + +export function buildNewWorkflowTemplate(filename: string): string { + const normalizedName = filename + .replace(/\.(yaml|yml)$/i, "") + .replace(/[^A-Za-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + const workflowName = normalizedName || "new_workflow"; + + return `name: ${workflowName}\ndescription: Draft workflow\nsteps:\n - id: start\n type: assign\n parameters:\n target: status\n value: ready\n`; +} + +export function formatMcpArgs(args: string[]): string { + return args.join("\n"); +} + +export function formatMcpEnv(env: Record): string { + return JSON.stringify(env, null, 2); +} + +export function parseMcpArgs(value: string): string[] { + return value + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function parseMcpEnv(value: string): Record { + const trimmed = value.trim(); + if (!trimmed) { + return {}; + } + + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("MCP env must be a JSON object."); + } + + return Object.fromEntries( + Object.entries(parsed as Record).map(([key, entry]) => { + if (typeof entry !== "string") { + throw new Error(`MCP env value for "${key}" must be a string.`); + } + + return [key, entry]; + }) + ); +} + +export function formatProbeSummary( + result: ConfigurationLlmProbeResult +): string { + if (!result.ok) { + return result.error || "Probe failed."; + } + + if (result.models && result.models.length > 0) { + return `Discovered ${result.models.length} models.`; + } + + if (result.modelsCount !== undefined) { + return `Probe succeeded with ${result.modelsCount} models.`; + } + + return "Probe succeeded."; +} diff --git a/apps/aevatar-console-web/src/pages/settings/runtimeSettingsWorkspaceSections.tsx b/apps/aevatar-console-web/src/pages/settings/runtimeSettingsWorkspaceSections.tsx new file mode 100644 index 00000000..542333e7 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/settings/runtimeSettingsWorkspaceSections.tsx @@ -0,0 +1,1489 @@ +import { ProCard, ProDescriptions, ProList } from "@ant-design/pro-components"; +import { + Alert, + Button, + Col, + Empty, + Input, + Row, + Select, + Space, + Tag, + Typography, +} from "antd"; +import React from "react"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import type { + ConfigurationEmbeddingsStatus, + ConfigurationLlmApiKeyStatus, + ConfigurationLlmInstance, + ConfigurationLlmProbeResult, + ConfigurationLlmProviderType, + ConfigurationMcpServer, + ConfigurationRawDocument, + ConfigurationSecp256k1Status, + ConfigurationSkillsMpStatus, + ConfigurationSourceStatus, + ConfigurationValidationResult, + ConfigurationWebSearchStatus, + ConfigurationWorkflowFile, +} from "@/shared/models/platform/configuration"; +import type { + ConfigurationPathRecord, + WorkflowDraftSource, +} from "./runtimeSettingsShared"; +import { formatProbeSummary, workflowKey } from "./runtimeSettingsShared"; + +type ReloadHandler = () => void; +type ActionHandler = () => void; +type ChangeHandler = (value: T) => void; + +export type SystemStatusSectionProps = { + configurationSourceError?: unknown; + configurationSourceStatus?: ConfigurationSourceStatus; + configurationSourceUnavailable: boolean; + configurationHealthReady: boolean; + configurationPathRecords: ConfigurationPathRecord[]; +}; + +export const SystemStatusSection: React.FC = ({ + configurationSourceError, + configurationSourceStatus, + configurationSourceUnavailable, + configurationHealthReady, + configurationPathRecords, +}) => ( + + {configurationSourceUnavailable ? ( + + ) : null} + + + rowKey="id" + search={false} + split + dataSource={configurationPathRecords} + locale={{ + emptyText: ( + + ), + }} + metas={{ + title: { + dataIndex: "label", + render: (_, record) => ( + + {record.label} + + {record.status.exists ? "exists" : "missing"} + + + {record.status.writable ? "writable" : "read-only"} + + + ), + }, + description: { + render: (_, record) => ( + + {record.status.path} + {record.status.error ? ( + + {record.status.error} + + ) : null} + + ), + }, + subTitle: { + render: (_, record) => ( + + size {record.status.sizeBytes ?? 0} bytes + + ), + }, + }} + /> + +); + +export type WorkflowFilesSectionProps = { + workflows: ConfigurationWorkflowFile[]; + selectedWorkflowId: string | null; + workflowFilename: string; + workflowSource: WorkflowDraftSource; + workflowContent: string; + canReloadSelectedWorkflow: boolean; + isSavingWorkflow: boolean; + isDeletingWorkflow: boolean; + onRefresh: ReloadHandler; + onSelectWorkflow: ChangeHandler; + onWorkflowFilenameChange: ChangeHandler; + onWorkflowSourceChange: ChangeHandler; + onWorkflowContentChange: ChangeHandler; + onNewDraft: ActionHandler; + onReloadSelectedWorkflow: ActionHandler; + onSaveWorkflow: ActionHandler; + onDeleteWorkflow: ActionHandler; +}; + +export const WorkflowFilesSection: React.FC = ({ + workflows, + selectedWorkflowId, + workflowFilename, + workflowSource, + workflowContent, + canReloadSelectedWorkflow, + isSavingWorkflow, + isDeletingWorkflow, + onRefresh, + onSelectWorkflow, + onWorkflowFilenameChange, + onWorkflowSourceChange, + onWorkflowContentChange, + onNewDraft, + onReloadSelectedWorkflow, + onSaveWorkflow, + onDeleteWorkflow, +}) => ( + + + Refresh} + > + + rowKey={(record) => workflowKey(record)} + search={false} + split + dataSource={workflows} + locale={{ + emptyText: ( + + ), + }} + metas={{ + title: { + dataIndex: "filename", + render: (_, record) => ( + + + {record.source} + + ), + }, + description: { + render: (_, record) => ( + + + {formatDateTime(record.lastModified)} + + {record.path} + + ), + }, + }} + /> + + + + + + + onWorkflowFilenameChange(event.target.value)} + /> + + style={{ width: 140 }} + value={workflowSource} + onChange={onWorkflowSourceChange} + options={[ + { label: "home", value: "home" }, + { label: "repo", value: "repo" }, + ]} + /> + + + + + + onWorkflowContentChange(event.target.value)} + placeholder="name: workflow_name" + /> + + Files are loaded from both home and repo roots. Save writes to the + selected target source. + + + + + +); + +export type EmbeddingsSectionProps = { + status?: ConfigurationEmbeddingsStatus; + enabledDraft: boolean; + providerTypeDraft: string; + endpointDraft: string; + modelDraft: string; + apiKeyDraft: string; + isRevealingApiKey: boolean; + isSaving: boolean; + isDeleting: boolean; + onReload: ReloadHandler; + onEnabledChange: ChangeHandler; + onProviderTypeChange: ChangeHandler; + onEndpointChange: ChangeHandler; + onModelChange: ChangeHandler; + onApiKeyChange: ChangeHandler; + onRevealApiKey: ActionHandler; + onSave: ActionHandler; + onDelete: ActionHandler; +}; + +export const EmbeddingsSection: React.FC = ({ + status, + enabledDraft, + providerTypeDraft, + endpointDraft, + modelDraft, + apiKeyDraft, + isRevealingApiKey, + isSaving, + isDeleting, + onReload, + onEnabledChange, + onProviderTypeChange, + onEndpointChange, + onModelChange, + onApiKeyChange, + onRevealApiKey, + onSave, + onDelete, +}) => ( + + + + + {status?.configured ? "API key configured" : "API key missing"} + + {enabledDraft ? "enabled" : "disabled"} + {status?.masked ? {status.masked} : null} + + + + onProviderTypeChange(event.target.value)} + /> + onEndpointChange(event.target.value)} + /> + onModelChange(event.target.value)} + /> + + + onApiKeyChange(event.target.value)} + /> + + + + + + Stored keys live under{" "} + LLMProviders:Embeddings:*. + + +); + +export type WebSearchSectionProps = { + status?: ConfigurationWebSearchStatus; + enabledDraft: boolean; + providerDraft: string; + endpointDraft: string; + timeoutDraft: string; + searchDepthDraft: string; + apiKeyDraft: string; + isRevealingApiKey: boolean; + isSaving: boolean; + isDeleting: boolean; + onReload: ReloadHandler; + onEnabledChange: ChangeHandler; + onProviderChange: ChangeHandler; + onEndpointChange: ChangeHandler; + onTimeoutChange: ChangeHandler; + onSearchDepthChange: ChangeHandler; + onApiKeyChange: ChangeHandler; + onRevealApiKey: ActionHandler; + onSave: ActionHandler; + onDelete: ActionHandler; +}; + +export const WebSearchSection: React.FC = ({ + status, + enabledDraft, + providerDraft, + endpointDraft, + timeoutDraft, + searchDepthDraft, + apiKeyDraft, + isRevealingApiKey, + isSaving, + isDeleting, + onReload, + onEnabledChange, + onProviderChange, + onEndpointChange, + onTimeoutChange, + onSearchDepthChange, + onApiKeyChange, + onRevealApiKey, + onSave, + onDelete, +}) => ( + + + + + {status?.configured ? "API key configured" : "API key missing"} + + {enabledDraft ? "enabled" : "disabled"} + {status?.masked ? {status.masked} : null} + + + + onProviderChange(event.target.value)} + /> + onEndpointChange(event.target.value)} + /> + onTimeoutChange(event.target.value)} + /> + onSearchDepthChange(event.target.value)} + /> + + + onApiKeyChange(event.target.value)} + /> + + + + + + Non-secret fields are written to{" "} + config.json. The key stays in{" "} + secrets.json. + + +); + +export type SkillsMpSectionProps = { + status?: ConfigurationSkillsMpStatus; + baseUrlDraft: string; + apiKeyDraft: string; + isRevealingApiKey: boolean; + isSaving: boolean; + isDeleting: boolean; + onReload: ReloadHandler; + onBaseUrlChange: ChangeHandler; + onApiKeyChange: ChangeHandler; + onRevealApiKey: ActionHandler; + onSave: ActionHandler; + onDelete: ActionHandler; +}; + +export const SkillsMpSection: React.FC = ({ + status, + baseUrlDraft, + apiKeyDraft, + isRevealingApiKey, + isSaving, + isDeleting, + onReload, + onBaseUrlChange, + onApiKeyChange, + onRevealApiKey, + onSave, + onDelete, +}) => ( + + + + + {status?.configured ? "API key configured" : "API key missing"} + + {status?.masked ? {status.masked} : null} + + + + onBaseUrlChange(event.target.value)} + /> + onApiKeyChange(event.target.value)} + /> + + + + + + + + API key path:{" "} + + {status?.keyPath ?? "SkillsMP:ApiKey"} + + + +); + +export type SignerKeySectionProps = { + status?: ConfigurationSecp256k1Status; + isGenerating: boolean; + onReload: ReloadHandler; + onGenerate: ActionHandler; +}; + +export const SignerKeySection: React.FC = ({ + status, + isGenerating, + onReload, + onGenerate, +}) => ( + + + + + {status?.configured ? "configured" : "not configured"} + + backups: {status?.privateKey.backupCount ?? 0} + + + + + Public key + + {status?.publicKey.hex ? ( + + Copy public key + + ) : null} + + + Private key status + + + Stored at{" "} + + {status?.privateKey.keyPath ?? "Crypto:EcdsaSecp256k1:PrivateKeyHex"} + + + + +); + +export type ConnectorsSectionProps = { + rawDocument?: ConfigurationCollectionRawDocumentLike; + connectorsJsonDraft: string; + isValidating: boolean; + isSaving: boolean; + onReload: ReloadHandler; + onValidate: ActionHandler; + onSave: ActionHandler; + onConnectorsJsonChange: ChangeHandler; +}; + +type ConfigurationCollectionRawDocumentLike = { + exists?: boolean; + count: number; + path?: string; +}; + +export const ConnectorsSection: React.FC = ({ + rawDocument, + connectorsJsonDraft, + isValidating, + isSaving, + onReload, + onValidate, + onSave, + onConnectorsJsonChange, +}) => ( + + + + + {rawDocument?.exists + ? "connectors.json present" + : "connectors.json missing"} + + entries: {rawDocument?.count ?? 0} + + + + + onConnectorsJsonChange(event.target.value)} + placeholder={'{\n "connectors": []\n}'} + /> + {rawDocument?.path ? ( + + Path: {rawDocument.path} + + ) : null} + +); + +export type LlmProvidersSectionProps = { + defaultProvider: string; + pendingDefaultProvider: string; + defaultOptions: Array<{ label: string; value: string }>; + providerTypeOptions: Array<{ label: string; value: string }>; + llmInstances: ConfigurationLlmInstance[]; + llmProviderTypes: ConfigurationLlmProviderType[]; + llmApiKeyStatus?: ConfigurationLlmApiKeyStatus; + selectedLlmInstanceName: string | null; + isNewLlmInstanceDraft: boolean; + llmInstanceNameDraft: string; + llmProviderTypeDraft: string; + llmModelDraft: string; + llmEndpointDraft: string; + llmApiKeyDraft: string; + llmProbeResult: ConfigurationLlmProbeResult | null; + llmModelsResult: ConfigurationLlmProbeResult | null; + isSettingDefaultProvider: boolean; + isSavingInstance: boolean; + isDeletingInstance: boolean; + isRevealingApiKey: boolean; + isSettingApiKey: boolean; + isDeletingApiKey: boolean; + isTestingConnection: boolean; + isFetchingModels: boolean; + onPendingDefaultProviderChange: ChangeHandler; + onSetDefaultProvider: ActionHandler; + onNewInstance: ActionHandler; + onLlmInstanceNameChange: ChangeHandler; + onLlmProviderTypeChange: ChangeHandler; + onLlmModelChange: ChangeHandler; + onLlmEndpointChange: ChangeHandler; + onLlmApiKeyChange: ChangeHandler; + onSaveInstance: ActionHandler; + onDeleteInstance: ActionHandler; + onRevealApiKey: ActionHandler; + onSetApiKey: ActionHandler; + onDeleteApiKey: ActionHandler; + onTestConnection: ActionHandler; + onFetchModels: ActionHandler; + onSelectInstance: ChangeHandler; +}; + +export const LlmProvidersSection: React.FC = ({ + defaultProvider, + pendingDefaultProvider, + defaultOptions, + providerTypeOptions, + llmInstances, + llmProviderTypes, + llmApiKeyStatus, + selectedLlmInstanceName, + isNewLlmInstanceDraft, + llmInstanceNameDraft, + llmProviderTypeDraft, + llmModelDraft, + llmEndpointDraft, + llmApiKeyDraft, + llmProbeResult, + llmModelsResult, + isSettingDefaultProvider, + isSavingInstance, + isDeletingInstance, + isRevealingApiKey, + isSettingApiKey, + isDeletingApiKey, + isTestingConnection, + isFetchingModels, + onPendingDefaultProviderChange, + onSetDefaultProvider, + onNewInstance, + onLlmInstanceNameChange, + onLlmProviderTypeChange, + onLlmModelChange, + onLlmEndpointChange, + onLlmApiKeyChange, + onSaveInstance, + onDeleteInstance, + onRevealApiKey, + onSetApiKey, + onDeleteApiKey, + onTestConnection, + onFetchModels, + onSelectInstance, +}) => ( + + + + Default provider: {defaultProvider || "default"} + + onLlmInstanceNameChange(event.target.value)} + /> + onLlmModelChange(event.target.value)} + /> + onLlmEndpointChange(event.target.value)} + /> + + + + + + + + onLlmApiKeyChange(event.target.value)} + /> + + + + + + + {llmApiKeyStatus?.configured + ? "api key configured" + : "api key missing"} + + {llmApiKeyStatus?.masked ? {llmApiKeyStatus.masked} : null} + + + + + + {llmProbeResult ? ( + + + {formatProbeSummary(llmProbeResult)} + + {llmProbeResult.sampleModels?.length ? ( + + {llmProbeResult.sampleModels.join(", ")} + + ) : null} + + } + /> + ) : null} + {llmModelsResult ? ( + + + {formatProbeSummary(llmModelsResult)} + + {llmModelsResult.models?.length ? ( + + {llmModelsResult.models.slice(0, 10).join(", ")} + + ) : null} + + } + /> + ) : null} + + + + + ), + }} + metas={{ + title: { + dataIndex: "name", + render: (_, record) => ( + + + {record.providerDisplayName} + + ), + }, + description: { + render: (_, record) => ( + + Model: {record.model} + {record.endpoint} + + ), + }, + }} + /> + + + + ), + }} + metas={{ + title: { + dataIndex: "displayName", + render: (_, record) => ( + + {record.displayName} + {record.category} + {record.recommended ? ( + recommended + ) : null} + + ), + }, + description: { + render: (_, record) => ( + {record.description} + ), + }, + subTitle: { + render: (_, record) => ( + + configured instances: {record.configuredInstancesCount} + + ), + }, + }} + /> + + +); + +export type McpSectionProps = { + servers: ConfigurationMcpServer[]; + rawDocument?: ConfigurationCollectionRawDocumentLike; + selectedMcpServerName: string | null; + isNewMcpDraft: boolean; + mcpNameDraft: string; + mcpCommandDraft: string; + mcpArgsDraft: string; + mcpEnvDraft: string; + mcpTimeoutDraft: string; + mcpJsonDraft: string; + isSavingServer: boolean; + isDeletingServer: boolean; + isValidatingRaw: boolean; + isSavingRaw: boolean; + onReloadServers: ReloadHandler; + onSelectServer: ChangeHandler; + onMcpNameChange: ChangeHandler; + onMcpCommandChange: ChangeHandler; + onMcpArgsChange: ChangeHandler; + onMcpEnvChange: ChangeHandler; + onMcpTimeoutChange: ChangeHandler; + onMcpJsonChange: ChangeHandler; + onNewServer: ActionHandler; + onSaveServer: ActionHandler; + onDeleteServer: ActionHandler; + onReloadRaw: ReloadHandler; + onValidateRaw: ActionHandler; + onSaveRaw: ActionHandler; +}; + +export const McpSection: React.FC = ({ + servers, + rawDocument, + selectedMcpServerName, + isNewMcpDraft, + mcpNameDraft, + mcpCommandDraft, + mcpArgsDraft, + mcpEnvDraft, + mcpTimeoutDraft, + mcpJsonDraft, + isSavingServer, + isDeletingServer, + isValidatingRaw, + isSavingRaw, + onReloadServers, + onSelectServer, + onMcpNameChange, + onMcpCommandChange, + onMcpArgsChange, + onMcpEnvChange, + onMcpTimeoutChange, + onMcpJsonChange, + onNewServer, + onSaveServer, + onDeleteServer, + onReloadRaw, + onValidateRaw, + onSaveRaw, +}) => ( + + + + + Refresh} + > + + rowKey="name" + search={false} + split + dataSource={servers} + locale={{ + emptyText: ( + + ), + }} + metas={{ + title: { + dataIndex: "name", + render: (_, record) => ( + + + {record.timeoutMs} ms + + ), + }, + description: { + render: (_, record) => ( + + {record.command} + + args: {record.args.length} · env:{" "} + {Object.keys(record.env).length} + + + ), + }, + }} + /> + + + + + + + onMcpNameChange(event.target.value)} + /> + onMcpCommandChange(event.target.value)} + /> + onMcpTimeoutChange(event.target.value)} + /> + + + + + + Args + onMcpArgsChange(event.target.value)} + placeholder={"node\nserver.js\n--transport\nstdio"} + /> + + One argument per line. + + + + Env + onMcpEnvChange(event.target.value)} + placeholder={'{\n "API_KEY": "value"\n}'} + /> + + Provide a JSON object with string values. + + + + + + + + + + + {rawDocument?.exists ? "mcp.json present" : "mcp.json missing"} + + servers: {rawDocument?.count ?? 0} + + + + + onMcpJsonChange(event.target.value)} + placeholder={'{\n "mcpServers": {}\n}'} + /> + {rawDocument?.path ? ( + + Path: {rawDocument.path} + + ) : null} + + + +); + +export type SecretsSectionProps = { + secretsJsonDraft: string; + secretKeyDraft: string; + secretValueDraft: string; + isSavingSecret: boolean; + isRemovingSecret: boolean; + isSavingRaw: boolean; + onReload: ReloadHandler; + onSecretsJsonChange: ChangeHandler; + onSecretKeyChange: ChangeHandler; + onSecretValueChange: ChangeHandler; + onSetSecret: ActionHandler; + onRemoveSecret: ActionHandler; + onSaveRaw: ActionHandler; +}; + +export const SecretsSection: React.FC = ({ + secretsJsonDraft, + secretKeyDraft, + secretValueDraft, + isSavingSecret, + isRemovingSecret, + isSavingRaw, + onReload, + onSecretsJsonChange, + onSecretKeyChange, + onSecretValueChange, + onSetSecret, + onRemoveSecret, + onSaveRaw, +}) => ( + + + + + + onSecretKeyChange(event.target.value)} + /> + onSecretValueChange(event.target.value)} + /> + + + + + Use key-based operations for small targeted changes. Raw JSON remains + available below for bulk edits. + + + + + + + + onSecretsJsonChange(event.target.value)} + placeholder={'{\n "LLMProviders": {}\n}'} + /> + +); + +export type RawConfigSectionProps = { + configJsonDraft: string; + isSaving: boolean; + onReload: ReloadHandler; + onConfigJsonChange: ChangeHandler; + onSave: ActionHandler; +}; + +export const RawConfigSection: React.FC = ({ + configJsonDraft, + isSaving, + onReload, + onConfigJsonChange, + onSave, +}) => ( + + + This editor writes the local runtime config JSON directly. + + + + + + onConfigJsonChange(event.target.value)} + placeholder={'{\n "Workflow": {}\n}'} + /> + +); diff --git a/apps/aevatar-console-web/src/pages/studio/components/StudioShell.test.tsx b/apps/aevatar-console-web/src/pages/studio/components/StudioShell.test.tsx index 99ad7cda..e56a936f 100644 --- a/apps/aevatar-console-web/src/pages/studio/components/StudioShell.test.tsx +++ b/apps/aevatar-console-web/src/pages/studio/components/StudioShell.test.tsx @@ -1,66 +1,63 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import React from 'react'; -import StudioShell, { type StudioShellNavItem } from './StudioShell'; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import StudioShell, { type StudioShellNavItem } from "./StudioShell"; -describe('StudioShell', () => { +describe("StudioShell", () => { const navItems: readonly StudioShellNavItem[] = [ { - key: 'workflows', - label: 'Workflows', - description: 'Browse workspace workflows and start new drafts.', + key: "workflows", + label: "Workflows", + description: "Browse workspace workflows and start new drafts.", count: 3, }, { - key: 'studio', - label: 'Studio', - description: 'Edit the active draft and inspect execution runs.', + key: "studio", + label: "Studio", + description: "Edit the active draft and inspect execution runs.", count: 0, }, { - key: 'roles', - label: 'Roles', - description: 'Edit, import, and save workflow role definitions.', + key: "roles", + label: "Roles", + description: "Edit, import, and save workflow role definitions.", }, ]; - it('renders a collapsible icon rail and forwards selection', () => { + it("renders a collapsible icon rail and forwards selection", () => { const handleSelectPage = jest.fn(); render( - React.createElement( - StudioShell, - { - currentPage: 'workflows', - navItems, - onSelectPage: handleSelectPage, - pageTitle: 'Studio page', - }, - React.createElement('div', null, 'Studio content'), - ), + React.createElement(StudioShell, { + currentPage: "workflows", + navItems, + onSelectPage: handleSelectPage, + pageTitle: "Studio page", + children: React.createElement("div", null, "Studio content"), + }) ); - expect(screen.getByLabelText('Workbench')).toHaveStyle({ width: '64px' }); - expect(screen.getByLabelText('Workbench navigation')).toBeTruthy(); - expect(screen.getByRole('button', { name: /workflows/i })).toHaveAttribute( - 'aria-current', - 'page', + expect(screen.getByLabelText("Workbench")).toHaveStyle({ width: "64px" }); + expect(screen.getByLabelText("Workbench navigation")).toBeTruthy(); + expect(screen.getByRole("button", { name: /workflows/i })).toHaveAttribute( + "aria-current", + "page" ); expect( - screen.queryByText('Browse workspace workflows and start new drafts.'), + screen.queryByText("Browse workspace workflows and start new drafts.") ).toBeNull(); - expect(screen.queryByText('Workflows')).toBeNull(); + expect(screen.queryByText("Workflows")).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: /studio/i })); + fireEvent.click(screen.getByRole("button", { name: /studio/i })); - expect(handleSelectPage).toHaveBeenCalledWith('studio'); + expect(handleSelectPage).toHaveBeenCalledWith("studio"); - fireEvent.click(screen.getByRole('button', { name: 'Expand workbench' })); + fireEvent.click(screen.getByRole("button", { name: "Expand workbench" })); - expect(screen.getByLabelText('Workbench')).toHaveStyle({ width: '160px' }); - expect(screen.getByText('Workflows')).toBeTruthy(); - expect(screen.getByText('Collapse')).toBeTruthy(); + expect(screen.getByLabelText("Workbench")).toHaveStyle({ width: "160px" }); + expect(screen.getByText("Workflows")).toBeTruthy(); + expect(screen.getByText("Collapse")).toBeTruthy(); expect( - screen.getByRole('button', { name: 'Collapse workbench' }), - ).toHaveAttribute('aria-pressed', 'true'); + screen.getByRole("button", { name: "Collapse workbench" }) + ).toHaveAttribute("aria-pressed", "true"); }); }); diff --git a/apps/aevatar-console-web/src/pages/studio/components/StudioWorkbenchSections.tsx b/apps/aevatar-console-web/src/pages/studio/components/StudioWorkbenchSections.tsx index 57cf9b52..b882e684 100644 --- a/apps/aevatar-console-web/src/pages/studio/components/StudioWorkbenchSections.tsx +++ b/apps/aevatar-console-web/src/pages/studio/components/StudioWorkbenchSections.tsx @@ -124,6 +124,10 @@ type StudioSettingsDraftLike = { readonly providers: StudioProviderSettings[]; }; +function isScopeDirectoryPath(path: string): boolean { + return path.trim().startsWith('scope://'); +} + export type StudioWorkflowLayout = 'grid' | 'list'; const workflowSectionShellStyle: React.CSSProperties = { @@ -179,6 +183,9 @@ const workflowBrowserStyle: React.CSSProperties = { boxShadow: '0 26px 64px rgba(17, 24, 39, 0.08)', overflow: 'hidden', minHeight: 640, + width: '100%', + flex: 1, + minWidth: 0, display: 'flex', flexDirection: 'column', }; @@ -211,19 +218,35 @@ const workflowDirectorySelectButtonStyle: React.CSSProperties = { minWidth: 0, padding: 0, textAlign: 'left', + whiteSpace: 'normal', +}; + +const workflowDirectoryTextStackStyle: React.CSSProperties = { + ...cardStackStyle, + minWidth: 0, }; const workflowDirectoryLabelStyle: React.CSSProperties = { + display: 'block', fontSize: 13, fontWeight: 600, color: '#1F2937', + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', }; const workflowDirectoryPathStyle: React.CSSProperties = { + display: 'block', fontSize: 11, lineHeight: 1.6, color: '#9CA3AF', marginTop: 4, + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', }; const workflowToolbarLayoutStyle: React.CSSProperties = { @@ -969,6 +992,9 @@ export const StudioWorkflowsPage: React.FC = ({
{directories.map((directory) => { const active = selectedDirectoryId === directory.directoryId; + const showDirectoryPath = + workflowStorageMode !== 'scope' && + !isScopeDirectoryPath(directory.path); return (
= ({ style={workflowDirectorySelectButtonStyle} onClick={() => onSelectDirectoryId(directory.directoryId)} > -
- +
+ {directory.label} - - {directory.path} - + {showDirectoryPath ? ( + + {directory.path} + + ) : null}
{!directory.isBuiltIn && workflowStorageMode !== 'scope' ? ( @@ -5426,29 +5460,37 @@ export const StudioSettingsPage: React.FC = ({ ) : null} {workspaceSettings.data.directories.length > 0 ? (
- {workspaceSettings.data.directories.map((directory) => ( -
-
- - {directory.label} - {directory.isBuiltIn ? built-in : null} - - - {directory.path} - - {canManageDirectories && !directory.isBuiltIn ? ( - - ) : null} + {workspaceSettings.data.directories.map((directory) => { + const showDirectoryPath = + workflowStorageMode !== 'scope' && + !isScopeDirectoryPath(directory.path); + + return ( +
+
+ + {directory.label} + {directory.isBuiltIn ? built-in : null} + + {showDirectoryPath ? ( + + {directory.path} + + ) : null} + {canManageDirectories && !directory.isBuiltIn ? ( + + ) : null} +
-
- ))} + ); + })}
) : ( ({ +jest.mock("@/shared/studio/api", () => ({ studioApi: { getAppContext: jest.fn(async () => ({ - mode: 'proxy', + mode: "proxy", scopeId: null, scopeResolved: false, - scopeSource: '', - workflowStorageMode: 'workspace', + scopeSource: "", + workflowStorageMode: "workspace", features: { publishedWorkflows: true, scripts: false, @@ -215,42 +236,42 @@ jest.mock('@/shared/studio/api', () => ({ getAuthSession: jest.fn(async () => ({ enabled: false, authenticated: false, - providerDisplayName: 'NyxID', + providerDisplayName: "NyxID", })), getWorkspaceSettings: jest.fn(async () => ({ - runtimeBaseUrl: 'http://127.0.0.1:5100', + runtimeBaseUrl: "http://127.0.0.1:5100", directories: [ { - directoryId: 'dir-1', - label: 'Workspace', - path: '/tmp/workflows', + directoryId: "dir-1", + label: "Workspace", + path: "/tmp/workflows", isBuiltIn: false, }, ], })), listWorkflows: jest.fn(async () => [ { - workflowId: 'workflow-1', - name: 'workspace-demo', - description: 'Workspace workflow', - fileName: 'workspace-demo.yaml', - filePath: '/tmp/workflows/workspace-demo.yaml', - directoryId: 'dir-1', - directoryLabel: 'Workspace', + workflowId: "workflow-1", + name: "workspace-demo", + description: "Workspace workflow", + fileName: "workspace-demo.yaml", + filePath: "/tmp/workflows/workspace-demo.yaml", + directoryId: "dir-1", + directoryLabel: "Workspace", stepCount: 2, hasLayout: true, - updatedAtUtc: '2026-03-18T00:00:00Z', + updatedAtUtc: "2026-03-18T00:00:00Z", }, ]), getTemplateWorkflow: jest.fn(async () => ({ catalog: { - name: 'published-demo', - description: 'Published demo workflow', + name: "published-demo", + description: "Published demo workflow", }, - yaml: 'name: published-demo\ndescription: Published demo workflow\nsteps: []\n', + yaml: "name: published-demo\ndescription: Published demo workflow\nsteps: []\n", definition: { - name: 'published-demo', - description: 'Published demo workflow', + name: "published-demo", + description: "Published demo workflow", closedWorldMode: false, roles: [], steps: [], @@ -273,7 +294,7 @@ jest.mock('@/shared/studio/api', () => ({ fileName: input.fileName || mockWorkflowFile.fileName, directoryId: input.directoryId, yaml: input.yaml, - updatedAtUtc: '2026-03-18T00:05:00Z', + updatedAtUtc: "2026-03-18T00:05:00Z", document: { ...mockWorkflowFile.document, name: input.workflowName, @@ -281,38 +302,38 @@ jest.mock('@/shared/studio/api', () => ({ }; return mockWorkflowFile; - }, + } ), parseYaml: jest.fn(async (input: { yaml: string }) => ({ - document: input.yaml.includes('name: legacy_draft') + document: input.yaml.includes("name: legacy_draft") ? { - name: 'legacy_draft', - description: '', + name: "legacy_draft", + description: "", roles: [], steps: [], } - : input.yaml.includes('name: published-demo') - ? { - name: 'published-demo', - description: 'Published demo workflow', - roles: [], - steps: [], - } - : input.yaml.includes('name: draft') - ? { - name: 'draft', - description: '', - roles: [], - steps: [], - } - : input.yaml.includes('name: ai-generated') - ? { - name: 'ai-generated', - description: 'Generated by Studio AI', - roles: [], - steps: [], - } - : mockParsedDocument, + : input.yaml.includes("name: published-demo") + ? { + name: "published-demo", + description: "Published demo workflow", + roles: [], + steps: [], + } + : input.yaml.includes("name: draft") + ? { + name: "draft", + description: "", + roles: [], + steps: [], + } + : input.yaml.includes("name: ai-generated") + ? { + name: "ai-generated", + description: "Generated by Studio AI", + roles: [], + steps: [], + } + : mockParsedDocument, findings: [], })), serializeYaml: jest.fn( @@ -329,65 +350,65 @@ jest.mock('@/shared/studio/api', () => ({ document: mockParsedDocument, findings: [], }; - }, + } ), listExecutions: jest.fn(async () => [ { - executionId: 'execution-1', - workflowName: 'workspace-demo', - prompt: 'Run the demo workflow.', - status: 'running', - startedAtUtc: '2026-03-18T00:00:00Z', + executionId: "execution-1", + workflowName: "workspace-demo", + prompt: "Run the demo workflow.", + status: "running", + startedAtUtc: "2026-03-18T00:00:00Z", completedAtUtc: null, - actorId: 'actor-1', + actorId: "actor-1", error: null, }, ]), getExecution: jest.fn(async (executionId: string) => ({ executionId, - workflowName: 'workspace-demo', + workflowName: "workspace-demo", prompt: - executionId === 'execution-2' - ? 'Run the active draft from Studio.' - : 'Run the demo workflow.', - status: 'running', + executionId === "execution-2" + ? "Run the active draft from Studio." + : "Run the demo workflow.", + status: "running", startedAtUtc: - executionId === 'execution-2' - ? '2026-03-18T00:06:00Z' - : '2026-03-18T00:00:00Z', + executionId === "execution-2" + ? "2026-03-18T00:06:00Z" + : "2026-03-18T00:00:00Z", completedAtUtc: null, - actorId: executionId === 'execution-2' ? 'actor-2' : 'actor-1', + actorId: executionId === "execution-2" ? "actor-2" : "actor-1", error: null, frames: [], })), startExecution: jest.fn( async (input: { workflowName: string; prompt: string }) => ({ - executionId: 'execution-2', + executionId: "execution-2", workflowName: input.workflowName, prompt: input.prompt, - runtimeBaseUrl: 'http://127.0.0.1:5100', - status: 'running', - startedAtUtc: '2026-03-18T00:06:00Z', + runtimeBaseUrl: "http://127.0.0.1:5100", + status: "running", + startedAtUtc: "2026-03-18T00:06:00Z", completedAtUtc: null, - actorId: 'actor-2', + actorId: "actor-2", error: null, frames: [ { - receivedAtUtc: '2026-03-18T00:06:01Z', + receivedAtUtc: "2026-03-18T00:06:01Z", payload: '{"event":"started"}', }, ], - }), + }) ), stopExecution: jest.fn(async (executionId: string) => ({ executionId, - workflowName: 'workspace-demo', - prompt: 'Run the demo workflow.', - runtimeBaseUrl: 'http://127.0.0.1:5100', - status: 'stopped', - startedAtUtc: '2026-03-18T00:00:00Z', - completedAtUtc: '2026-03-18T00:07:00Z', - actorId: 'actor-1', + workflowName: "workspace-demo", + prompt: "Run the demo workflow.", + runtimeBaseUrl: "http://127.0.0.1:5100", + status: "stopped", + startedAtUtc: "2026-03-18T00:00:00Z", + completedAtUtc: "2026-03-18T00:07:00Z", + actorId: "actor-1", error: null, frames: [], })), @@ -401,7 +422,7 @@ jest.mock('@/shared/studio/api', () => ({ mockConnectorDraftResponse = { ...mockConnectorDraftResponse, fileExists: true, - updatedAtUtc: '2026-03-18T00:03:00Z', + updatedAtUtc: "2026-03-18T00:03:00Z", draft: input.draft, }; return mockConnectorDraftResponse; @@ -421,39 +442,39 @@ jest.mock('@/shared/studio/api', () => ({ connectors: input.connectors, }; return mockConnectorCatalog; - }, + } ), importConnectorCatalog: jest.fn(async (file: File) => { mockConnectorCatalog = { ...mockConnectorCatalog, connectors: [ { - name: 'imported-search', - type: 'http', + name: "imported-search", + type: "http", enabled: true, timeoutMs: 15000, retry: 2, http: { - baseUrl: 'https://imported.example.test', - allowedMethods: ['POST'], - allowedPaths: ['/catalog'], - allowedInputKeys: ['query'], + baseUrl: "https://imported.example.test", + allowedMethods: ["POST"], + allowedPaths: ["/catalog"], + allowedInputKeys: ["query"], defaultHeaders: {}, }, cli: { - command: '', + command: "", fixedArguments: [], allowedOperations: [], allowedInputKeys: [], - workingDirectory: '', + workingDirectory: "", environment: {}, }, mcp: { - serverName: '', - command: '', + serverName: "", + command: "", arguments: [], environment: {}, - defaultTool: '', + defaultTool: "", allowedTools: [], allowedInputKeys: [], }, @@ -477,7 +498,7 @@ jest.mock('@/shared/studio/api', () => ({ mockRoleDraftResponse = { ...mockRoleDraftResponse, fileExists: true, - updatedAtUtc: '2026-03-18T00:03:00Z', + updatedAtUtc: "2026-03-18T00:03:00Z", draft: input.draft, }; return mockRoleDraftResponse; @@ -497,19 +518,19 @@ jest.mock('@/shared/studio/api', () => ({ roles: input.roles, }; return mockRoleCatalog; - }, + } ), importRoleCatalog: jest.fn(async (file: File) => { mockRoleCatalog = { ...mockRoleCatalog, roles: [ { - id: 'reviewer', - name: 'Reviewer', - systemPrompt: 'Review imported workflow outputs carefully.', - provider: 'tornado', - model: 'gpt-review', - connectors: ['imported-search'], + id: "reviewer", + name: "Reviewer", + systemPrompt: "Review imported workflow outputs carefully.", + provider: "tornado", + model: "gpt-review", + connectors: ["imported-search"], }, ], }; @@ -537,22 +558,26 @@ jest.mock('@/shared/studio/api', () => ({ providers: input.providers || mockSettings.providers, }; return mockSettings; - }, + } + ), + testRuntimeConnection: jest.fn( + async (input: { runtimeBaseUrl?: string }) => ({ + runtimeBaseUrl: input.runtimeBaseUrl || mockSettings.runtimeBaseUrl, + reachable: true, + checkedUrl: `${ + input.runtimeBaseUrl || mockSettings.runtimeBaseUrl + }/health`, + statusCode: 200, + message: "Runtime responded with 200 OK.", + }) ), - testRuntimeConnection: jest.fn(async (input: { runtimeBaseUrl?: string }) => ({ - runtimeBaseUrl: input.runtimeBaseUrl || mockSettings.runtimeBaseUrl, - reachable: true, - checkedUrl: `${input.runtimeBaseUrl || mockSettings.runtimeBaseUrl}/health`, - statusCode: 200, - message: 'Runtime responded with 200 OK.', - })), addWorkflowDirectory: jest.fn(async () => ({ - runtimeBaseUrl: 'http://127.0.0.1:5100', + runtimeBaseUrl: "http://127.0.0.1:5100", directories: [ { - directoryId: 'dir-1', - label: 'Workspace', - path: '/tmp/workflows', + directoryId: "dir-1", + label: "Workspace", + path: "/tmp/workflows", isBuiltIn: false, }, ], @@ -564,408 +589,398 @@ jest.mock('@/shared/studio/api', () => ({ options?: { onText?: (text: string) => void; onReasoning?: (text: string) => void; - }, + } ) => { - options?.onReasoning?.('Thinking through the workflow structure.'); - options?.onText?.('name: ai-generated\nsteps: []\n'); - return 'name: ai-generated\nsteps: []\n'; - }, + options?.onReasoning?.("Thinking through the workflow structure."); + options?.onText?.("name: ai-generated\nsteps: []\n"); + return "name: ai-generated\nsteps: []\n"; + } ), }, })); -jest.mock('./components/StudioBootstrapGate', () => ({ +jest.mock("./components/StudioBootstrapGate", () => ({ __esModule: true, - default: ({ children }) => children, + default: ({ children }: MockChildrenProps) => children, })); -jest.mock('./components/StudioShell', () => ({ +jest.mock("./components/StudioShell", () => ({ __esModule: true, - default: ({ children }) => { - const React = require('react'); + default: ({ children }: MockChildrenProps) => { + const React = require("react"); return React.createElement( - 'div', + "div", null, - React.createElement('div', null, 'Workbench'), - children, + React.createElement("div", null, "Workbench"), + children ); }, })); -jest.mock('./components/StudioWorkbenchSections', () => { - const React = require('react'); +jest.mock("./components/StudioWorkbenchSections", () => { + const React = require("react"); - const renderNoticeTitle = (key, notice, successTitle, errorTitle) => { + const renderNoticeTitle = ( + key: string, + notice: MockNotice, + successTitle: string, + errorTitle: string + ) => { if (!notice) { return null; } return React.createElement( - 'div', + "div", { key }, - notice.type === 'error' ? errorTitle : successTitle, + notice.type === "error" ? errorTitle : successTitle ); }; - const StudioWorkflowsPage = (props) => - React.createElement( - 'div', - null, - [ - React.createElement('h2', { key: 'title' }, 'Workflows'), - React.createElement('div', { key: 'draft' }, 'Current draft'), - React.createElement('input', { - key: 'search', - placeholder: 'Search workflows', - value: props.workflowSearch ?? '', - onChange: (event) => props.onSetWorkflowSearch?.(event.target.value), - }), - ...((props.workflows.data ?? []).map((workflow) => - React.createElement( - 'button', - { - key: workflow.workflowId, - type: 'button', - onClick: () => props.onOpenWorkflow?.(workflow.workflowId), - }, - workflow.name, - ), - )), + const StudioWorkflowsPage = (props: any) => + React.createElement("div", null, [ + React.createElement("h2", { key: "title" }, "Workflows"), + React.createElement("div", { key: "draft" }, "Current draft"), + React.createElement("input", { + key: "search", + placeholder: "Search workflows", + value: props.workflowSearch ?? "", + onChange: (event: MockValueEvent) => + props.onSetWorkflowSearch?.(event.target.value), + }), + ...(props.workflows.data ?? []).map((workflow: any) => React.createElement( - 'button', + "button", { - key: 'blank', - type: 'button', - onClick: () => props.onStartBlankDraft?.(), + key: workflow.workflowId, + type: "button", + onClick: () => props.onOpenWorkflow?.(workflow.workflowId), }, - 'Start blank draft', - ), - ], - ); + workflow.name + ) + ), + React.createElement( + "button", + { + key: "blank", + type: "button", + onClick: () => props.onStartBlankDraft?.(), + }, + "Start blank draft" + ), + ]); - const StudioEditorPage = (props) => { + const StudioEditorPage = (props: any) => { const [runOpen, setRunOpen] = React.useState(false); const [askAiOpen, setAskAiOpen] = React.useState(false); const title = - props.draftMode === 'new' - ? props.draftWorkflowName === 'legacy_draft' - ? 'Imported local draft' - : 'Blank Studio draft' + props.draftMode === "new" + ? props.draftWorkflowName === "legacy_draft" + ? "Imported local draft" + : "Blank Studio draft" : props.templateWorkflowName - ? 'Published template draft' - : 'Current draft'; + ? "Published template draft" + : "Current draft"; if (!props.draftYaml) { - return React.createElement('div', null, 'No draft loaded'); + return React.createElement("div", null, "No draft loaded"); } return React.createElement( - 'div', + "div", null, [ - React.createElement('div', { key: 'title' }, title), - React.createElement('div', { key: 'graph-title' }, 'Workflow graph'), + React.createElement("div", { key: "title" }, title), + React.createElement("div", { key: "graph-title" }, "Workflow graph"), renderNoticeTitle( - 'save-notice', + "save-notice", props.saveNotice, - 'Workflow saved', - 'Workflow save failed', + "Workflow saved", + "Workflow save failed" ), renderNoticeTitle( - 'run-notice', + "run-notice", props.runNotice, - 'Run started', - 'Run failed', + "Run started", + "Run failed" ), renderNoticeTitle( - 'ask-ai-notice', + "ask-ai-notice", props.askAiNotice, - 'Studio AI generation updated the draft', - 'Studio AI generation failed', + "Studio AI generation updated the draft", + "Studio AI generation failed" ), - React.createElement('input', { - key: 'workflow-name', - 'aria-label': 'Workflow name', - value: props.draftWorkflowName ?? '', - onChange: (event) => props.onSetDraftWorkflowName?.(event.target.value), + React.createElement("input", { + key: "workflow-name", + "aria-label": "Workflow name", + value: props.draftWorkflowName ?? "", + onChange: (event: MockValueEvent) => + props.onSetDraftWorkflowName?.(event.target.value), }), - React.createElement('textarea', { - key: 'workflow-yaml', - 'aria-label': 'Workflow YAML', - value: props.draftYaml ?? '', - onChange: (event) => props.onSetDraftYaml?.(event.target.value), + React.createElement("textarea", { + key: "workflow-yaml", + "aria-label": "Workflow YAML", + value: props.draftYaml ?? "", + onChange: (event: MockValueEvent) => + props.onSetDraftYaml?.(event.target.value), }), React.createElement( - 'button', + "button", { - key: 'save', - type: 'button', + key: "save", + type: "button", onClick: () => props.onSaveDraft?.(), }, - 'Save to workspace', + "Save to workspace" ), React.createElement( - 'button', + "button", { - key: 'yaml', - type: 'button', - onClick: () => props.onSetInspectorTab?.('yaml'), + key: "yaml", + type: "button", + onClick: () => props.onSetInspectorTab?.("yaml"), }, - 'YAML', + "YAML" ), - props.inspectorTab === 'yaml' - ? React.createElement( - 'div', - { key: 'yaml-panel' }, - [ - React.createElement( - 'div', - { key: 'yaml-title' }, - 'Validated by Studio editor', - ), - React.createElement('textarea', { - key: 'yaml-view', - 'aria-label': 'Studio workflow yaml panel', - readOnly: true, - value: props.draftYaml ?? '', - }), - ], - ) + props.inspectorTab === "yaml" + ? React.createElement("div", { key: "yaml-panel" }, [ + React.createElement( + "div", + { key: "yaml-title" }, + "Validated by Studio editor" + ), + React.createElement("textarea", { + key: "yaml-view", + "aria-label": "Studio workflow yaml panel", + readOnly: true, + value: props.draftYaml ?? "", + }), + ]) : null, props.recentPromptHistory?.length - ? React.createElement( - 'div', - { key: 'recent-prompts' }, - [ - React.createElement('div', { key: 'label' }, 'Recent prompts'), - React.createElement( - 'button', - { - key: 'reuse', - type: 'button', - onClick: () => - props.onReusePrompt?.(props.recentPromptHistory[0].prompt), - }, - 'Reuse prompt', - ), - ], - ) + ? React.createElement("div", { key: "recent-prompts" }, [ + React.createElement("div", { key: "label" }, "Recent prompts"), + React.createElement( + "button", + { + key: "reuse", + type: "button", + onClick: () => + props.onReusePrompt?.(props.recentPromptHistory[0].prompt), + }, + "Reuse prompt" + ), + ]) : null, React.createElement( - 'button', + "button", { - key: 'run-toggle', - type: 'button', + key: "run-toggle", + type: "button", onClick: () => setRunOpen(true), }, - 'Run', + "Run" ), runOpen - ? React.createElement( - 'div', - { key: 'run-dialog' }, - [ - React.createElement('textarea', { - key: 'run-prompt', - 'aria-label': 'Studio execution prompt', - value: props.runPrompt ?? '', - onChange: (event) => - props.onRunPromptChange?.(event.target.value), - }), - React.createElement( - 'button', - { - key: 'run-submit', - type: 'button', - onClick: () => props.onStartExecution?.(), - }, - 'Run', - ), - ], - ) + ? React.createElement("div", { key: "run-dialog" }, [ + React.createElement("textarea", { + key: "run-prompt", + "aria-label": "Studio execution prompt", + value: props.runPrompt ?? "", + onChange: (event: MockValueEvent) => + props.onRunPromptChange?.(event.target.value), + }), + React.createElement( + "button", + { + key: "run-submit", + type: "button", + onClick: () => props.onStartExecution?.(), + }, + "Run" + ), + ]) : null, React.createElement( - 'button', + "button", { - key: 'ask-ai-toggle', - type: 'button', + key: "ask-ai-toggle", + type: "button", onClick: () => setAskAiOpen(true), }, - 'Open Ask AI', + "Open Ask AI" ), askAiOpen - ? React.createElement( - 'div', - { key: 'ask-ai-panel' }, - [ - React.createElement('textarea', { - key: 'ask-ai-prompt', - 'aria-label': 'Studio AI workflow prompt', - value: props.askAiPrompt ?? '', - onChange: (event) => - props.onAskAiPromptChange?.(event.target.value), - }), - React.createElement( - 'button', - { - key: 'ask-ai-submit', - type: 'button', - onClick: () => props.onAskAiGenerate?.(), - }, - 'Generate', - ), - ], - ) + ? React.createElement("div", { key: "ask-ai-panel" }, [ + React.createElement("textarea", { + key: "ask-ai-prompt", + "aria-label": "Studio AI workflow prompt", + value: props.askAiPrompt ?? "", + onChange: (event: MockValueEvent) => + props.onAskAiPromptChange?.(event.target.value), + }), + React.createElement( + "button", + { + key: "ask-ai-submit", + type: "button", + onClick: () => props.onAskAiGenerate?.(), + }, + "Generate" + ), + ]) : null, - ].filter(Boolean), + ].filter(Boolean) ); }; - const StudioExecutionPage = (props) => + const StudioExecutionPage = (props: any) => React.createElement( - 'div', + "div", null, [ - React.createElement('div', { key: 'logs' }, 'Logs'), + React.createElement("div", { key: "logs" }, "Logs"), renderNoticeTitle( - 'execution-notice', + "execution-notice", props.executionNotice, - 'Execution stop requested', - 'Execution stop failed', + "Execution stop requested", + "Execution stop failed" ), React.createElement( - 'button', + "button", { - key: 'stop', - type: 'button', + key: "stop", + type: "button", onClick: () => props.onStopExecution?.(), }, - 'Stop', + "Stop" ), - ].filter(Boolean), + ].filter(Boolean) ); - const StudioRolesPage = (props) => { - const selectedRole = props.selectedRole ?? props.roleCatalogDraft?.[0] ?? null; + const StudioRolesPage = (props: any) => { + const selectedRole = + props.selectedRole ?? props.roleCatalogDraft?.[0] ?? null; return React.createElement( - 'div', + "div", null, [ - React.createElement('input', { - key: 'role-import', - 'aria-label': 'Import role catalog file', - type: 'file', + React.createElement("input", { + key: "role-import", + "aria-label": "Import role catalog file", + type: "file", onChange: props.onRoleImportChange, }), - React.createElement('div', { key: 'label' }, 'Saved roles'), - React.createElement('input', { - key: 'search', - placeholder: 'Search roles', - value: props.roleSearch ?? '', - onChange: (event) => props.onRoleSearchChange?.(event.target.value), + React.createElement("div", { key: "label" }, "Saved roles"), + React.createElement("input", { + key: "search", + placeholder: "Search roles", + value: props.roleSearch ?? "", + onChange: (event: MockValueEvent) => + props.onRoleSearchChange?.(event.target.value), }), selectedRole - ? React.createElement('textarea', { - key: 'system-prompt', - 'aria-label': 'System prompt', - value: selectedRole.systemPrompt ?? '', - onChange: (event) => - props.onUpdateRoleCatalog?.(selectedRole.key, (role) => ({ + ? React.createElement("textarea", { + key: "system-prompt", + "aria-label": "System prompt", + value: selectedRole.systemPrompt ?? "", + onChange: (event: MockValueEvent) => + props.onUpdateRoleCatalog?.(selectedRole.key, (role: any) => ({ ...role, systemPrompt: event.target.value, })), }) : null, React.createElement( - 'button', + "button", { - key: 'use', - type: 'button', + key: "use", + type: "button", onClick: () => props.onApplyRoleToWorkflow?.(selectedRole?.key), }, - 'Use', + "Use" ), React.createElement( - 'button', + "button", { - key: 'save', - type: 'button', + key: "save", + type: "button", onClick: () => props.onSaveRoles?.(), }, - 'Save', + "Save" ), - ].filter(Boolean), + ].filter(Boolean) ); }; - const StudioConnectorsPage = (props) => { + const StudioConnectorsPage = (props: any) => { const selectedConnector = props.selectedConnector ?? props.connectorCatalogDraft?.[0] ?? null; return React.createElement( - 'div', + "div", null, [ - React.createElement('input', { - key: 'connector-import', - 'aria-label': 'Import connector catalog file', - type: 'file', + React.createElement("input", { + key: "connector-import", + "aria-label": "Import connector catalog file", + type: "file", onChange: props.onConnectorImportChange, }), - React.createElement('input', { - key: 'search', - placeholder: 'Search connectors', - value: props.connectorSearch ?? '', - onChange: (event) => + React.createElement("input", { + key: "search", + placeholder: "Search connectors", + value: props.connectorSearch ?? "", + onChange: (event: MockValueEvent) => props.onConnectorSearchChange?.(event.target.value), }), selectedConnector - ? React.createElement('input', { - key: 'base-url', - 'aria-label': 'Base URL', - value: selectedConnector.http?.baseUrl ?? '', - onChange: (event) => + ? React.createElement("input", { + key: "base-url", + "aria-label": "Base URL", + value: selectedConnector.http?.baseUrl ?? "", + onChange: (event: MockValueEvent) => props.onUpdateConnectorCatalog?.( selectedConnector.key, - (connector) => ({ + (connector: any) => ({ ...connector, http: { ...connector.http, baseUrl: event.target.value, }, - }), + }) ), }) : null, React.createElement( - 'button', + "button", { - key: 'save', - type: 'button', + key: "save", + type: "button", onClick: () => props.onSaveConnectors?.(), }, - 'Save', + "Save" ), - ].filter(Boolean), + ].filter(Boolean) ); }; - const StudioSettingsPage = (props) => + const StudioSettingsPage = (props: any) => React.createElement( - 'div', + "div", null, [ - React.createElement('div', { key: 'label' }, 'Provider settings'), + React.createElement("div", { key: "label" }, "Provider settings"), React.createElement( - 'div', - { key: 'selected-provider' }, - `Selected provider: ${props.selectedProvider?.providerName ?? 'none'}`, + "div", + { key: "selected-provider" }, + `Selected provider: ${props.selectedProvider?.providerName ?? "none"}` ), - React.createElement('input', { - key: 'runtime-base-url', - 'aria-label': 'Studio runtime base URL', - value: props.settingsDraft?.runtimeBaseUrl ?? '', - disabled: props.hostMode !== 'proxy', - onChange: (event) => { + React.createElement("input", { + key: "runtime-base-url", + "aria-label": "Studio runtime base URL", + value: props.settingsDraft?.runtimeBaseUrl ?? "", + disabled: props.hostMode !== "proxy", + onChange: (event: MockValueEvent) => { const nextValue = event.target.value; props.onSetSettingsDraft?.( props.settingsDraft @@ -973,36 +988,36 @@ jest.mock('./components/StudioWorkbenchSections', () => { ...props.settingsDraft, runtimeBaseUrl: nextValue, } - : props.settingsDraft, + : props.settingsDraft ); }, }), React.createElement( - 'button', + "button", { - key: 'save', - type: 'button', + key: "save", + type: "button", disabled: !props.settingsDirty, onClick: () => props.onSaveSettings?.(), }, - 'Save settings', + "Save settings" ), React.createElement( - 'button', + "button", { - key: 'test-runtime', - type: 'button', + key: "test-runtime", + type: "button", onClick: () => props.onTestRuntime?.(), }, - props.hostMode === 'proxy' ? 'Test runtime' : 'Check host runtime', + props.hostMode === "proxy" ? "Test runtime" : "Check host runtime" ), renderNoticeTitle( - 'settings-notice', + "settings-notice", props.settingsNotice, - 'Settings updated', - 'Settings update failed', + "Settings updated", + "Settings update failed" ), - ].filter(Boolean), + ].filter(Boolean) ); return { @@ -1017,21 +1032,21 @@ jest.mock('./components/StudioWorkbenchSections', () => { }; }); -function renderStudioPage(route = '/studio') { - window.history.pushState({}, '', route); +function renderStudioPage(route = "/studio") { + window.history.pushState({}, "", route); return renderWithQueryClient(React.createElement(StudioPage)); } -describe('StudioPage', () => { +describe("StudioPage", () => { beforeEach(() => { - window.history.pushState({}, '', '/studio'); + window.history.pushState({}, "", "/studio"); window.localStorage.clear(); resetMockState(); jest.clearAllMocks(); }); - it('loads workspace data and shows the Studio workbench by default', async () => { - renderStudioPage('/studio'); + it("loads workspace data and shows the Studio workbench by default", async () => { + renderStudioPage("/studio"); await waitFor(() => { expect(studioApi.getAppContext).toHaveBeenCalled(); @@ -1042,222 +1057,229 @@ describe('StudioPage', () => { expect(studioApi.getSettings).toHaveBeenCalled(); }); - expect(await screen.findByText('workspace-demo')).toBeTruthy(); - expect(screen.getByText('Workbench')).toBeTruthy(); - expect(screen.getByText('Workflows')).toBeTruthy(); - expect(screen.getByText('Current draft')).toBeTruthy(); - expect(screen.getByPlaceholderText('Search workflows')).toBeTruthy(); + expect(await screen.findByText("workspace-demo")).toBeTruthy(); + expect(screen.getByText("Workbench")).toBeTruthy(); + expect(screen.getByText("Workflows")).toBeTruthy(); + expect(screen.getByText("Current draft")).toBeTruthy(); + expect(screen.getByPlaceholderText("Search workflows")).toBeTruthy(); }); - it('saves edited workflow drafts back to the Studio workspace API', async () => { - renderStudioPage('/studio?workflow=workflow-1&tab=studio'); + it("saves edited workflow drafts back to the Studio workspace API", async () => { + renderStudioPage("/studio?workflow=workflow-1&tab=studio"); - const editor = await screen.findByLabelText('Workflow YAML'); + const editor = await screen.findByLabelText("Workflow YAML"); fireEvent.change(editor, { target: { - value: 'name: workspace-demo\nsteps:\n - id: approve_step\n', + value: "name: workspace-demo\nsteps:\n - id: approve_step\n", }, }); - fireEvent.click(screen.getByRole('button', { name: 'Save to workspace' })); + fireEvent.click(screen.getByRole("button", { name: "Save to workspace" })); await waitFor(() => { expect(studioApi.saveWorkflow).toHaveBeenCalledWith( expect.objectContaining({ - workflowId: 'workflow-1', - directoryId: 'dir-1', - workflowName: 'workspace-demo', - yaml: 'name: workspace-demo\nsteps:\n - id: approve_step\n', - }), + workflowId: "workflow-1", + directoryId: "dir-1", + workflowName: "workspace-demo", + yaml: "name: workspace-demo\nsteps:\n - id: approve_step\n", + }) ); }); - expect(await screen.findByText('Workflow saved')).toBeTruthy(); + expect(await screen.findByText("Workflow saved")).toBeTruthy(); }); - it('starts a blank draft when the Studio route requests draft mode', async () => { - renderStudioPage('/studio?draft=new'); + it("starts a blank draft when the Studio route requests draft mode", async () => { + renderStudioPage("/studio?draft=new"); - expect(await screen.findByText('Blank Studio draft')).toBeTruthy(); - expect((await screen.findByLabelText('Workflow name')) as HTMLInputElement).toHaveValue( - 'draft', - ); - expect((await screen.findByLabelText('Workflow YAML')) as HTMLTextAreaElement).toHaveValue( - 'name: draft\nsteps: []\n', - ); + expect(await screen.findByText("Blank Studio draft")).toBeTruthy(); + expect( + (await screen.findByLabelText("Workflow name")) as HTMLInputElement + ).toHaveValue("draft"); + expect( + (await screen.findByLabelText("Workflow YAML")) as HTMLTextAreaElement + ).toHaveValue("name: draft\nsteps: []\n"); }); - it('hydrates a Studio draft from the legacy playground handoff', async () => { + it("hydrates a Studio draft from the legacy playground handoff", async () => { savePlaygroundDraft({ - yaml: 'name: legacy_draft\nsteps:\n - id: legacy_step\n', - sourceWorkflow: 'legacy_draft', - prompt: 'Carry this draft into Studio.', + yaml: "name: legacy_draft\nsteps:\n - id: legacy_step\n", + sourceWorkflow: "legacy_draft", + prompt: "Carry this draft into Studio.", }); - renderStudioPage('/studio?draft=new&legacy=playground'); + renderStudioPage("/studio?draft=new&legacy=playground"); - expect(await screen.findByText('Imported local draft')).toBeTruthy(); - expect((await screen.findByLabelText('Workflow name')) as HTMLInputElement).toHaveValue( - 'legacy_draft', - ); - expect((await screen.findByLabelText('Workflow YAML')) as HTMLTextAreaElement).toHaveValue( - 'name: legacy_draft\nsteps:\n - id: legacy_step\n', - ); + expect(await screen.findByText("Imported local draft")).toBeTruthy(); + expect( + (await screen.findByLabelText("Workflow name")) as HTMLInputElement + ).toHaveValue("legacy_draft"); + expect( + (await screen.findByLabelText("Workflow YAML")) as HTMLTextAreaElement + ).toHaveValue("name: legacy_draft\nsteps:\n - id: legacy_step\n"); }); - it('hydrates the Studio execution prompt from the route query', async () => { + it("hydrates the Studio execution prompt from the route query", async () => { renderStudioPage( - '/studio?template=published-demo&prompt=Continue%20this%20workflow%20in%20Studio', + "/studio?template=published-demo&prompt=Continue%20this%20workflow%20in%20Studio" ); await waitFor(() => { - expect(studioApi.getTemplateWorkflow).toHaveBeenCalledWith('published-demo'); + expect(studioApi.getTemplateWorkflow).toHaveBeenCalledWith( + "published-demo" + ); }); - fireEvent.click(await screen.findByRole('button', { name: 'Run' })); + fireEvent.click(await screen.findByRole("button", { name: "Run" })); expect( (await screen.findByLabelText( - 'Studio execution prompt', - )) as HTMLTextAreaElement, - ).toHaveValue('Continue this workflow in Studio'); + "Studio execution prompt" + )) as HTMLTextAreaElement + ).toHaveValue("Continue this workflow in Studio"); }); - it('reuses legacy prompt history inside Studio', async () => { + it("reuses legacy prompt history inside Studio", async () => { window.localStorage.setItem( PROMPT_HISTORY_STORAGE_KEY, JSON.stringify([ { - id: 'workspace-demo:Review the current draft carefully.', - prompt: 'Review the current draft carefully.', - workflowName: 'workspace-demo', - updatedAt: '2026-03-18T00:02:00Z', + id: "workspace-demo:Review the current draft carefully.", + prompt: "Review the current draft carefully.", + workflowName: "workspace-demo", + updatedAt: "2026-03-18T00:02:00Z", }, - ]), + ]) ); - renderStudioPage('/studio?draft=new'); + renderStudioPage("/studio?draft=new"); - expect(await screen.findByText('Recent prompts')).toBeTruthy(); + expect(await screen.findByText("Recent prompts")).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Reuse prompt' })); - fireEvent.click(screen.getByRole('button', { name: 'Run' })); + fireEvent.click(screen.getByRole("button", { name: "Reuse prompt" })); + fireEvent.click(screen.getByRole("button", { name: "Run" })); expect( (await screen.findByLabelText( - 'Studio execution prompt', - )) as HTMLTextAreaElement, - ).toHaveValue('Review the current draft carefully.'); + "Studio execution prompt" + )) as HTMLTextAreaElement + ).toHaveValue("Review the current draft carefully."); }); - it('starts a Studio execution from the active draft', async () => { - renderStudioPage('/studio?workflow=workflow-1&tab=studio'); + it("starts a Studio execution from the active draft", async () => { + renderStudioPage("/studio?workflow=workflow-1&tab=studio"); - fireEvent.click(await screen.findByRole('button', { name: 'Run' })); - fireEvent.change(await screen.findByLabelText('Studio execution prompt'), { + fireEvent.click(await screen.findByRole("button", { name: "Run" })); + fireEvent.change(await screen.findByLabelText("Studio execution prompt"), { target: { - value: 'Run the active draft from Studio.', + value: "Run the active draft from Studio.", }, }); - fireEvent.click(screen.getAllByRole('button', { name: 'Run' }).at(-1)!); + fireEvent.click(screen.getAllByRole("button", { name: "Run" }).at(-1)!); await waitFor(() => { expect(studioApi.startExecution).toHaveBeenCalledWith( expect.objectContaining({ - workflowName: 'workspace-demo', - prompt: 'Run the active draft from Studio.', - workflowYamls: [expect.stringContaining('name: workspace-demo')], - runtimeBaseUrl: 'http://127.0.0.1:5100', - }), + workflowName: "workspace-demo", + prompt: "Run the active draft from Studio.", + workflowYamls: [expect.stringContaining("name: workspace-demo")], + runtimeBaseUrl: "http://127.0.0.1:5100", + }) ); }); - expect(await screen.findByText('Logs')).toBeTruthy(); + expect(await screen.findByText("Logs")).toBeTruthy(); }); - it('stops the selected Studio execution from the execution view', async () => { - renderStudioPage('/studio?workflow=workflow-1&tab=executions&execution=execution-1'); + it("stops the selected Studio execution from the execution view", async () => { + renderStudioPage( + "/studio?workflow=workflow-1&tab=executions&execution=execution-1" + ); - expect(await screen.findByText('Logs')).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + expect(await screen.findByText("Logs")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Stop" })); await waitFor(() => { - expect(studioApi.stopExecution).toHaveBeenCalledWith('execution-1', { - reason: 'user requested stop', + expect(studioApi.stopExecution).toHaveBeenCalledWith("execution-1", { + reason: "user requested stop", }); }); - expect(await screen.findByText('Execution stop requested')).toBeTruthy(); + expect(await screen.findByText("Execution stop requested")).toBeTruthy(); }); - it('generates workflow YAML with Studio AI and applies it to the draft', async () => { - renderStudioPage('/studio?draft=new&tab=studio'); + it("generates workflow YAML with Studio AI and applies it to the draft", async () => { + renderStudioPage("/studio?draft=new&tab=studio"); - fireEvent.click(await screen.findByRole('button', { name: 'Open Ask AI' })); - fireEvent.change(await screen.findByLabelText('Studio AI workflow prompt'), { - target: { - value: 'Create a short review workflow', - }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Generate' })); + fireEvent.click(await screen.findByRole("button", { name: "Open Ask AI" })); + fireEvent.change( + await screen.findByLabelText("Studio AI workflow prompt"), + { + target: { + value: "Create a short review workflow", + }, + } + ); + fireEvent.click(screen.getByRole("button", { name: "Generate" })); await waitFor(() => { expect(studioApi.authorWorkflow).toHaveBeenCalledWith( expect.objectContaining({ - prompt: 'Create a short review workflow', + prompt: "Create a short review workflow", }), - expect.any(Object), + expect.any(Object) ); }); - fireEvent.click(screen.getByRole('button', { name: 'YAML' })); + fireEvent.click(screen.getByRole("button", { name: "YAML" })); await waitFor(() => { expect( ( screen.getByLabelText( - 'Studio workflow yaml panel', + "Studio workflow yaml panel" ) as HTMLTextAreaElement - ).value.trim(), - ).toBe('name: ai-generated\nsteps: []'); + ).value.trim() + ).toBe("name: ai-generated\nsteps: []"); }); }); - it('saves edited role catalog entries through the Studio API', async () => { - renderStudioPage('/studio?tab=roles'); + it("saves edited role catalog entries through the Studio API", async () => { + renderStudioPage("/studio?tab=roles"); - expect(await screen.findByPlaceholderText('Search roles')).toBeTruthy(); - expect(await screen.findByDisplayValue('Help the operator.')).toBeTruthy(); + expect(await screen.findByPlaceholderText("Search roles")).toBeTruthy(); + expect(await screen.findByDisplayValue("Help the operator.")).toBeTruthy(); - fireEvent.change(screen.getByLabelText('System prompt'), { + fireEvent.change(screen.getByLabelText("System prompt"), { target: { - value: 'Answer carefully and keep responses concise.', + value: "Answer carefully and keep responses concise.", }, }); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); + fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { expect(studioApi.saveRoleCatalog).toHaveBeenCalledWith( expect.objectContaining({ roles: expect.arrayContaining([ expect.objectContaining({ - id: 'assistant', - systemPrompt: 'Answer carefully and keep responses concise.', + id: "assistant", + systemPrompt: "Answer carefully and keep responses concise.", }), ]), - }), + }) ); }); }); - it('imports role catalog entries through the Studio upload API', async () => { - renderStudioPage('/studio?tab=roles'); + it("imports role catalog entries through the Studio upload API", async () => { + renderStudioPage("/studio?tab=roles"); - const file = new File(['{"roles":[]}'], 'roles-import.json', { - type: 'application/json', + const file = new File(['{"roles":[]}'], "roles-import.json", { + type: "application/json", }); - fireEvent.change(await screen.findByLabelText('Import role catalog file'), { + fireEvent.change(await screen.findByLabelText("Import role catalog file"), { target: { files: [file], }, @@ -1269,54 +1291,58 @@ describe('StudioPage', () => { expect( await screen.findByDisplayValue( - 'Review imported workflow outputs carefully.', - ), + "Review imported workflow outputs carefully." + ) ).toBeTruthy(); }); - it('saves edited connector catalog entries through the Studio API', async () => { - renderStudioPage('/studio?tab=connectors'); + it("saves edited connector catalog entries through the Studio API", async () => { + renderStudioPage("/studio?tab=connectors"); - expect(await screen.findByPlaceholderText('Search connectors')).toBeTruthy(); - expect(await screen.findByDisplayValue('https://example.test')).toBeTruthy(); + expect( + await screen.findByPlaceholderText("Search connectors") + ).toBeTruthy(); + expect( + await screen.findByDisplayValue("https://example.test") + ).toBeTruthy(); - fireEvent.change(screen.getByLabelText('Base URL'), { + fireEvent.change(screen.getByLabelText("Base URL"), { target: { - value: 'https://console.example.test', + value: "https://console.example.test", }, }); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); + fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { expect(studioApi.saveConnectorCatalog).toHaveBeenCalledWith( expect.objectContaining({ connectors: expect.arrayContaining([ expect.objectContaining({ - name: 'web-search', + name: "web-search", http: expect.objectContaining({ - baseUrl: 'https://console.example.test', + baseUrl: "https://console.example.test", }), }), ]), - }), + }) ); }); }); - it('imports connector catalog entries through the Studio upload API', async () => { - renderStudioPage('/studio?tab=connectors'); + it("imports connector catalog entries through the Studio upload API", async () => { + renderStudioPage("/studio?tab=connectors"); - const file = new File(['{"connectors":[]}'], 'connectors-import.json', { - type: 'application/json', + const file = new File(['{"connectors":[]}'], "connectors-import.json", { + type: "application/json", }); fireEvent.change( - await screen.findByLabelText('Import connector catalog file'), + await screen.findByLabelText("Import connector catalog file"), { target: { files: [file], }, - }, + } ); await waitFor(() => { @@ -1324,83 +1350,85 @@ describe('StudioPage', () => { }); expect( - await screen.findByDisplayValue('https://imported.example.test'), + await screen.findByDisplayValue("https://imported.example.test") ).toBeTruthy(); }); - it('saves editable Studio settings and provider configuration', async () => { - renderStudioPage('/studio?tab=settings'); + it("saves editable Studio settings and provider configuration", async () => { + renderStudioPage("/studio?tab=settings"); - expect(await screen.findByText('Provider settings')).toBeTruthy(); - expect(await screen.findByText('Selected provider: tornado')).toBeTruthy(); + expect(await screen.findByText("Provider settings")).toBeTruthy(); + expect(await screen.findByText("Selected provider: tornado")).toBeTruthy(); const runtimeBaseUrlInput = await screen.findByLabelText( - 'Studio runtime base URL', + "Studio runtime base URL" ); fireEvent.change(runtimeBaseUrlInput, { target: { - value: 'http://127.0.0.1:5111', + value: "http://127.0.0.1:5111", }, }); await waitFor(() => { - expect(runtimeBaseUrlInput).toHaveValue('http://127.0.0.1:5111'); + expect(runtimeBaseUrlInput).toHaveValue("http://127.0.0.1:5111"); }); - fireEvent.click(screen.getByRole('button', { name: 'Save settings' })); + fireEvent.click(screen.getByRole("button", { name: "Save settings" })); await waitFor(() => { expect(studioApi.saveSettings).toHaveBeenCalledWith( expect.objectContaining({ - runtimeBaseUrl: 'http://127.0.0.1:5111', - defaultProviderName: 'tornado', - }), + runtimeBaseUrl: "http://127.0.0.1:5111", + defaultProviderName: "tornado", + }) ); }); - expect(await screen.findByText('Settings updated')).toBeTruthy(); + expect(await screen.findByText("Settings updated")).toBeTruthy(); }); - it('treats runtime connection as host-managed in embedded mode', async () => { + it("treats runtime connection as host-managed in embedded mode", async () => { (studioApi.getAppContext as jest.Mock).mockResolvedValueOnce({ - mode: 'embedded', + mode: "embedded", scopeId: null, scopeResolved: false, - scopeSource: '', - workflowStorageMode: 'workspace', + scopeSource: "", + workflowStorageMode: "workspace", features: { publishedWorkflows: true, scripts: false, }, }); - renderStudioPage('/studio?tab=settings'); + renderStudioPage("/studio?tab=settings"); const runtimeBaseUrlInput = await screen.findByLabelText( - 'Studio runtime base URL', + "Studio runtime base URL" ); expect(runtimeBaseUrlInput).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Save settings' })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Save settings" }) + ).toBeDisabled(); - fireEvent.click(screen.getByRole('button', { name: 'Save settings' })); + fireEvent.click(screen.getByRole("button", { name: "Save settings" })); expect(studioApi.saveSettings).not.toHaveBeenCalled(); expect( - screen.getByRole('button', { name: 'Check host runtime' }), + screen.getByRole("button", { name: "Check host runtime" }) ).toBeTruthy(); }); - it('applies a saved role to the current workflow from the roles catalog', async () => { - renderStudioPage('/studio?workflow=workflow-1&tab=roles'); + it("applies a saved role to the current workflow from the roles catalog", async () => { + renderStudioPage("/studio?workflow=workflow-1&tab=roles"); - expect(await screen.findByText('Saved roles')).toBeTruthy(); + expect(await screen.findByText("Saved roles")).toBeTruthy(); await waitFor(() => { expect(studioApi.parseYaml).toHaveBeenCalledWith( expect.objectContaining({ - yaml: expect.stringContaining('name: workspace-demo'), - }), + yaml: expect.stringContaining("name: workspace-demo"), + }) ); }); - fireEvent.click(screen.getByRole('button', { name: 'Use' })); + fireEvent.click(screen.getByRole("button", { name: "Use" })); await waitFor(() => { expect(studioApi.serializeYaml).toHaveBeenCalledWith( @@ -1408,11 +1436,11 @@ describe('StudioPage', () => { document: expect.objectContaining({ roles: expect.arrayContaining([ expect.objectContaining({ - id: 'assistant_2', + id: "assistant_2", }), ]), }), - }), + }) ); }); }); diff --git a/apps/aevatar-console-web/src/pages/workflows/index.test.tsx b/apps/aevatar-console-web/src/pages/workflows/index.test.tsx index d2c161fd..5b4c0e88 100644 --- a/apps/aevatar-console-web/src/pages/workflows/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflows/index.test.tsx @@ -1,11 +1,11 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { consoleApi } from '@/shared/api/consoleApi'; +import { runtimeCatalogApi } from '@/shared/api/runtimeCatalogApi'; import { renderWithQueryClient } from '../../../tests/reactQueryTestUtils'; import WorkflowsPage from './index'; -jest.mock('@/shared/api/consoleApi', () => ({ - consoleApi: { +jest.mock('@/shared/api/runtimeCatalogApi', () => ({ + runtimeCatalogApi: { listWorkflowCatalog: jest.fn(async () => [ { name: 'demo_flow', @@ -106,7 +106,7 @@ describe('WorkflowsPage', () => { renderWithQueryClient(React.createElement(WorkflowsPage)); await waitFor(() => { - expect(consoleApi.listWorkflowCatalog).toHaveBeenCalled(); + expect(runtimeCatalogApi.listWorkflowCatalog).toHaveBeenCalled(); }); expect(await screen.findAllByText('demo_flow')).toBeTruthy(); @@ -117,9 +117,7 @@ describe('WorkflowsPage', () => { expect( await screen.findByRole('combobox', { name: 'Groups' }), ).toBeTruthy(); - expect( - screen.getByRole('button', { name: 'More actions for demo_flow' }), - ).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Inspect' })).toBeTruthy(); expect( screen.getByRole('button', { name: 'Copy workflow YAML' }), ).toBeTruthy(); @@ -132,7 +130,7 @@ describe('WorkflowsPage', () => { renderWithQueryClient(React.createElement(WorkflowsPage)); await waitFor(() => { - expect(consoleApi.listWorkflowCatalog).toHaveBeenCalled(); + expect(runtimeCatalogApi.listWorkflowCatalog).toHaveBeenCalled(); }); expect( @@ -167,7 +165,9 @@ describe('WorkflowsPage', () => { renderWithQueryClient(React.createElement(WorkflowsPage)); await waitFor(() => { - expect(consoleApi.getWorkflowDetail).toHaveBeenCalledWith('demo_flow'); + expect(runtimeCatalogApi.getWorkflowDetail).toHaveBeenCalledWith( + 'demo_flow', + ); }); fireEvent.click(await screen.findByRole('tab', { name: 'Graph' })); @@ -193,7 +193,9 @@ describe('WorkflowsPage', () => { renderWithQueryClient(React.createElement(WorkflowsPage)); await waitFor(() => { - expect(consoleApi.getWorkflowDetail).toHaveBeenCalledWith('demo_flow'); + expect(runtimeCatalogApi.getWorkflowDetail).toHaveBeenCalledWith( + 'demo_flow', + ); }); fireEvent.click(await screen.findByRole('tab', { name: 'Steps (2)' })); diff --git a/apps/aevatar-console-web/src/pages/workflows/index.tsx b/apps/aevatar-console-web/src/pages/workflows/index.tsx index da30e17f..805b002d 100644 --- a/apps/aevatar-console-web/src/pages/workflows/index.tsx +++ b/apps/aevatar-console-web/src/pages/workflows/index.tsx @@ -1,34 +1,29 @@ import { ApartmentOutlined, - CodeOutlined, - EditOutlined, FilterOutlined, FullscreenExitOutlined, FullscreenOutlined, MenuFoldOutlined, MenuUnfoldOutlined, - MoreOutlined, - PlayCircleOutlined, SearchOutlined, -} from '@ant-design/icons'; +} from "@ant-design/icons"; import type { ProColumns, ProDescriptionsItemProps, -} from '@ant-design/pro-components'; +} from "@ant-design/pro-components"; import { PageContainer, ProCard, ProDescriptions, ProTable, -} from '@ant-design/pro-components'; -import { useQuery } from '@tanstack/react-query'; -import { history } from '@umijs/max'; -import type { MenuProps } from 'antd'; +} from "@ant-design/pro-components"; +import { useQuery } from "@tanstack/react-query"; +import { history } from "@umijs/max"; +import type { MenuProps } from "antd"; import { Alert, Button, Col, - Dropdown, Empty, Input, Modal, @@ -38,9 +33,8 @@ import { Statistic, Tabs, Tag, - Tooltip, Typography, -} from 'antd'; +} from "antd"; import React, { useCallback, useContext, @@ -49,24 +43,15 @@ import React, { useMemo, useRef, useState, -} from 'react'; -import { consoleApi } from '@/shared/api/consoleApi'; -import type { WorkflowCatalogRole } from '@/shared/api/models'; -import { buildWorkflowGraphElements } from '@/shared/graphs/buildGraphElements'; -import GraphCanvas from '@/shared/graphs/GraphCanvas'; -import { getPlaygroundDraftStatus } from '@/shared/playground/draftStatus'; -import { - buildPlaygroundRoute, - buildYamlBrowserRoute, -} from '@/shared/playground/navigation'; -import { - loadPlaygroundDraft, - PLAYGROUND_DRAFT_UPDATED_EVENT, -} from '@/shared/playground/playgroundDraft'; +} from "react"; +import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; +import type { WorkflowCatalogRole } from "@/shared/models/runtime/catalog"; +import { buildWorkflowGraphElements } from "@/shared/graphs/buildGraphElements"; +import GraphCanvas from "@/shared/graphs/GraphCanvas"; import { listVisibleWorkflowCatalogItems, resolveWorkflowCatalogSelection, -} from '@/shared/workflows/catalogVisibility'; +} from "@/shared/workflows/catalogVisibility"; import { cardStackStyle, compactTableCardProps, @@ -75,8 +60,8 @@ import { moduleCardProps, scrollPanelStyle, stretchColumnStyle, -} from '@/shared/ui/proComponents'; -import WorkflowYamlViewer from './WorkflowYamlViewer'; +} from "@/shared/ui/proComponents"; +import WorkflowYamlViewer from "./WorkflowYamlViewer"; import { buildStepRows, buildStringOptions, @@ -87,12 +72,12 @@ import { type WorkflowLibraryFilter, type WorkflowLibraryRow, type WorkflowStepRow, -} from './workflowPresentation'; +} from "./workflowPresentation"; -type WorkflowDetailTab = 'yaml' | 'roles' | 'steps' | 'graph'; +type WorkflowDetailTab = "yaml" | "roles" | "steps" | "graph"; type WorkflowSummaryRecord = { - closedWorldStatus: 'success' | 'default'; + closedWorldStatus: "success" | "default"; roleCount: number; stepCount: number; edgeCount: number; @@ -101,7 +86,7 @@ type WorkflowSummaryRecord = { }; type WorkflowFocusRecord = { - focusType: 'role' | 'step'; + focusType: "role" | "step"; focusId: string; relatedRole: string; relatedStepCount: number; @@ -141,28 +126,28 @@ type DictionarySectionProps = { }; type ChildStepSectionProps = { - childrenSteps: WorkflowStepRow['children']; + childrenSteps: WorkflowStepRow["children"]; }; const llmValueEnum = { - processing: { text: 'Required', status: 'Processing' }, - success: { text: 'Optional', status: 'Success' }, + processing: { text: "Required", status: "Processing" }, + success: { text: "Optional", status: "Success" }, } as const; const llmFilterOptions = [ - { label: 'All', value: 'all' }, - { label: 'Requires LLM', value: 'required' }, - { label: 'Optional', value: 'optional' }, + { label: "All", value: "all" }, + { label: "Requires LLM", value: "required" }, + { label: "Optional", value: "optional" }, ] as const; -const focusTypeLabels: Record = { - role: 'Role', - step: 'Step', +const focusTypeLabels: Record = { + role: "Role", + step: "Step", }; const workflowNameCellStyle = { - display: 'flex', - flexDirection: 'column', + display: "flex", + flexDirection: "column", gap: 4, } as const; @@ -172,62 +157,62 @@ const compactFilterPanelStyle = { } as const; const workflowDetailHeaderStyle = { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'flex-start', + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", gap: 16, - flexWrap: 'wrap', + flexWrap: "wrap", } as const; const workflowDetailHeaderMainStyle = { - flex: '1 1 420px', + flex: "1 1 420px", minWidth: 0, - display: 'flex', - flexDirection: 'column', + display: "flex", + flexDirection: "column", gap: 12, } as const; const workflowDetailActionGroupStyle = { - flex: '0 0 auto', - display: 'flex', - justifyContent: 'flex-end', - flexWrap: 'wrap', + flex: "0 0 auto", + display: "flex", + justifyContent: "flex-end", + flexWrap: "wrap", gap: 8, } as const; const workflowDetailDescriptionStyle = { marginBottom: 0, - maxWidth: '100%', + maxWidth: "100%", } as const; const workflowSummaryCardStyle = { - height: '100%', - border: '1px solid var(--ant-color-border-secondary)', + height: "100%", + border: "1px solid var(--ant-color-border-secondary)", borderRadius: 12, padding: 12, - background: 'var(--ant-color-fill-quaternary)', + background: "var(--ant-color-fill-quaternary)", } as const; const collapsedLibraryBodyStyle = { ...embeddedPanelStyle, - alignItems: 'flex-start', - display: 'flex', - flexDirection: 'column', + alignItems: "flex-start", + display: "flex", + flexDirection: "column", gap: 12, minHeight: 220, - justifyContent: 'space-between', + justifyContent: "space-between", } as const; const splitPaneListShellStyle = { ...embeddedPanelStyle, - height: '100%', + height: "100%", padding: 8, } as const; const splitPaneDetailShellStyle = { ...embeddedPanelStyle, minHeight: 540, - background: 'var(--ant-color-fill-quaternary)', + background: "var(--ant-color-fill-quaternary)", } as const; const splitPaneScrollableListStyle = { @@ -237,50 +222,50 @@ const splitPaneScrollableListStyle = { } as const; const splitPaneItemButtonStyle = { - alignItems: 'flex-start', + alignItems: "flex-start", borderRadius: 12, - display: 'flex', - flexDirection: 'column', + display: "flex", + flexDirection: "column", gap: 6, - height: 'auto', - justifyContent: 'flex-start', - padding: '12px 14px', - textAlign: 'left', - whiteSpace: 'normal', - width: '100%', + height: "auto", + justifyContent: "flex-start", + padding: "12px 14px", + textAlign: "left", + whiteSpace: "normal", + width: "100%", } as const; const splitPaneItemMetaStyle = { - display: 'flex', - flexDirection: 'column', + display: "flex", + flexDirection: "column", gap: 4, - width: '100%', + width: "100%", } as const; const definitionSectionTitleStyle = { - alignItems: 'center', - display: 'flex', + alignItems: "center", + display: "flex", gap: 8, - justifyContent: 'space-between', + justifyContent: "space-between", marginBottom: 12, } as const; const detailMetaGridStyle = { - display: 'grid', + display: "grid", gap: 12, - gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', + gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", } as const; const detailActionRowStyle = { - display: 'flex', - flexWrap: 'wrap', + display: "flex", + flexWrap: "wrap", gap: 8, } as const; const detailTextBlockStyle = { marginBottom: 0, - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', + whiteSpace: "pre-wrap", + wordBreak: "break-word", } as const; const graphPanelShellStyle = { @@ -289,28 +274,28 @@ const graphPanelShellStyle = { } as const; const graphPanelHeaderStyle = { - alignItems: 'flex-start', - display: 'flex', + alignItems: "flex-start", + display: "flex", gap: 12, - justifyContent: 'space-between', + justifyContent: "space-between", marginBottom: 12, - flexWrap: 'wrap', + flexWrap: "wrap", } as const; const graphModalBodyStyle = { - display: 'flex', - flexDirection: 'column', + display: "flex", + flexDirection: "column", gap: 16, - height: '100%', + height: "100%", } as const; const highlightedTabLabelStyle = { - alignItems: 'center', + alignItems: "center", borderRadius: 999, - display: 'inline-flex', + display: "inline-flex", gap: 8, - padding: '4px 10px', - transition: 'all 0.2s ease', + padding: "4px 10px", + transition: "all 0.2s ease", } as const; const WorkflowGraphInteractionContext = @@ -321,7 +306,7 @@ function useWorkflowGraphInteraction(): WorkflowGraphInteractionContextValue { if (!value) { throw new Error( - 'Workflow graph interaction context is unavailable in this tree.', + "Workflow graph interaction context is unavailable in this tree." ); } @@ -329,10 +314,10 @@ function useWorkflowGraphInteraction(): WorkflowGraphInteractionContextValue { } function renderMetricValue( - value: number | string | null | undefined, + value: number | string | null | undefined ): number | string { - if (value === null || value === undefined || value === '') { - return 'n/a'; + if (value === null || value === undefined || value === "") { + return "n/a"; } return value; @@ -359,17 +344,17 @@ const DictionarySection: React.FC = ({
{key} - {value || 'n/a'} + {value || "n/a"}
))} @@ -399,7 +384,7 @@ const ChildStepSection: React.FC = ({
@@ -442,7 +427,7 @@ const WorkflowRoleSplitPanel: React.FC = ({
-
+
{roles.map((role) => { const isActive = role.id === activeRole.id; @@ -453,12 +438,12 @@ const WorkflowRoleSplitPanel: React.FC = ({ style={{ ...splitPaneItemButtonStyle, background: isActive - ? 'var(--ant-color-primary-bg)' - : 'transparent', + ? "var(--ant-color-primary-bg)" + : "transparent", border: `1px solid ${ isActive - ? 'var(--ant-color-primary-border)' - : 'transparent' + ? "var(--ant-color-primary-border)" + : "transparent" }`, }} type="text" @@ -467,7 +452,7 @@ const WorkflowRoleSplitPanel: React.FC = ({
{role.id} - {role.name || 'Unnamed role'} + {role.name || "Unnamed role"}
@@ -496,16 +481,16 @@ const WorkflowRoleSplitPanel: React.FC = ({ Role ID · {activeRole.id}
- {activeRole.provider || 'No provider'} + {activeRole.provider || "No provider"} - {activeRole.model || 'No model'} + {activeRole.model || "No model"}
@@ -565,7 +550,7 @@ const WorkflowRoleSplitPanel: React.FC = ({ System prompt
- {activeRole.systemPrompt || 'No system prompt provided.'} + {activeRole.systemPrompt || "No system prompt provided."}
@@ -614,7 +599,7 @@ const WorkflowRoleSplitPanel: React.FC = ({ Event routes
- {activeRole.eventRoutes || 'No explicit event routes provided.'} + {activeRole.eventRoutes || "No explicit event routes provided."}
@@ -649,7 +634,7 @@ const WorkflowStepSplitPanel: React.FC = ({
-
+
{steps.map((step) => { const isActive = step.id === activeStep.id; @@ -660,12 +645,12 @@ const WorkflowStepSplitPanel: React.FC = ({ style={{ ...splitPaneItemButtonStyle, background: isActive - ? 'var(--ant-color-primary-bg)' - : 'transparent', + ? "var(--ant-color-primary-bg)" + : "transparent", border: `1px solid ${ isActive - ? 'var(--ant-color-primary-border)' - : 'transparent' + ? "var(--ant-color-primary-border)" + : "transparent" }`, }} type="text" @@ -699,7 +684,7 @@ const WorkflowStepSplitPanel: React.FC = ({ {activeStep.type} step @@ -738,11 +723,11 @@ const WorkflowStepSplitPanel: React.FC = ({
- +
= ({ const focusColumns: ProDescriptionsItemProps[] = [ { - title: 'Focus type', - dataIndex: 'focusType', + title: "Focus type", + dataIndex: "focusType", render: (_, record) => focusTypeLabels[record.focusType], }, { - title: 'Identifier', - dataIndex: 'focusId', + title: "Identifier", + dataIndex: "focusId", render: (_, record) => ( {record.focusId} ), }, { - title: 'Related role', - dataIndex: 'relatedRole', - render: (_, record) => record.relatedRole || 'n/a', + title: "Related role", + dataIndex: "relatedRole", + render: (_, record) => record.relatedRole || "n/a", }, { - title: 'Related steps', - dataIndex: 'relatedStepCount', - valueType: 'digit', + title: "Related steps", + dataIndex: "relatedStepCount", + valueType: "digit", }, { - title: 'Graph node', - dataIndex: 'graphNodeId', + title: "Graph node", + dataIndex: "graphNodeId", render: (_, record) => ( {record.graphNodeId} ), @@ -810,26 +795,26 @@ const focusColumns: ProDescriptionsItemProps[] = [ function parseDetailTab(value: string | null): WorkflowDetailTab { if ( - value === 'yaml' || - value === 'roles' || - value === 'steps' || - value === 'graph' + value === "yaml" || + value === "roles" || + value === "steps" || + value === "graph" ) { return value; } - return 'yaml'; + return "yaml"; } function readInitialSelection(): { workflow: string; tab: WorkflowDetailTab } { - if (typeof window === 'undefined') { - return { workflow: '', tab: 'yaml' }; + if (typeof window === "undefined") { + return { workflow: "", tab: "yaml" }; } const params = new URLSearchParams(window.location.search); return { - workflow: params.get('workflow') ?? '', - tab: parseDetailTab(params.get('tab')), + workflow: params.get("workflow") ?? "", + tab: parseDetailTab(params.get("tab")), }; } @@ -839,7 +824,7 @@ function sortFilterValues(values: string[]): string[] { function areWorkflowFiltersEqual( left: WorkflowLibraryFilter, - right: WorkflowLibraryFilter, + right: WorkflowLibraryFilter ): boolean { return ( left.keyword.trim() === right.keyword.trim() && @@ -857,7 +842,7 @@ function countAdvancedFilters(filters: WorkflowLibraryFilter): number { return [ filters.groups.length > 0, filters.sources.length > 0, - filters.llmRequirement !== 'all', + filters.llmRequirement !== "all", filters.primitives.length > 0, ].filter(Boolean).length; } @@ -874,30 +859,28 @@ function summarizeAppliedFilters(filters: WorkflowLibraryFilter): string { if (filters.sources.length > 0) { parts.push(`${filters.sources.length} source filter(s)`); } - if (filters.llmRequirement !== 'all') { + if (filters.llmRequirement !== "all") { parts.push( - filters.llmRequirement === 'required' - ? 'Requires LLM only' - : 'Optional LLM only', + filters.llmRequirement === "required" + ? "Requires LLM only" + : "Optional LLM only" ); } if (filters.primitives.length > 0) { parts.push(`${filters.primitives.length} primitive filter(s)`); } - return parts.length > 0 ? parts.join(' · ') : 'All workflows'; + return parts.length > 0 ? parts.join(" · ") : "All workflows"; } function createWorkflowColumns( onInspect: (workflowName: string) => void, - onRun: (workflowName: string) => void, - onOpenYaml: (workflowName: string) => void, - onOpenDraft: (workflowName: string) => void, + onRun: (workflowName: string) => void ): ProColumns[] { return [ { - title: 'Workflow', - dataIndex: 'name', + title: "Workflow", + dataIndex: "name", width: 260, render: (_, record) => (
@@ -905,26 +888,26 @@ function createWorkflowColumns( - {record.description || 'No description provided.'} + {record.description || "No description provided."}
), }, { - title: 'Group', - dataIndex: 'groupLabel', + title: "Group", + dataIndex: "groupLabel", width: 140, }, { - title: 'Source', - dataIndex: 'sourceLabel', + title: "Source", + dataIndex: "sourceLabel", width: 120, }, { - title: 'Primitives', - dataIndex: 'primitives', + title: "Primitives", + dataIndex: "primitives", width: 220, render: (_, record) => { const visiblePrimitives = record.primitives.slice(0, 2); @@ -948,81 +931,39 @@ function createWorkflowColumns( }, }, { - title: 'LLM', - dataIndex: 'llmStatus', + title: "LLM", + dataIndex: "llmStatus", width: 100, - valueType: 'status' as any, + valueType: "status" as any, valueEnum: llmValueEnum, }, { - title: 'Actions', - valueType: 'option', - width: 104, - align: 'right', - render: (_, record) => { - const items: MenuProps['items'] = [ - { - key: 'inspect', - label: 'Inspect', - icon: , - }, - { - key: 'yaml', - label: 'Open YAML', - icon: , - }, - { - key: 'draft', - label: 'Open draft', - icon: , - }, - ]; - - return ( - - - + + + ), }, ]; } @@ -1030,89 +971,91 @@ function createWorkflowColumns( const WorkflowsPage: React.FC = () => { const initialSelection = useMemo(() => readInitialSelection(), []); const [filters, setFilters] = useState( - defaultWorkflowLibraryFilter, + defaultWorkflowLibraryFilter ); const [filterDraft, setFilterDraft] = useState( - defaultWorkflowLibraryFilter, + defaultWorkflowLibraryFilter ); const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [isLibraryCollapsed, setIsLibraryCollapsed] = useState(false); const [selectedWorkflow, setSelectedWorkflow] = useState( - initialSelection.workflow, + initialSelection.workflow ); const [activeDetailTab, setActiveDetailTab] = useState( - initialSelection.tab, + initialSelection.tab ); - const [selectedRoleId, setSelectedRoleId] = useState(''); - const [selectedStepId, setSelectedStepId] = useState(''); - const [selectedGraphNodeId, setSelectedGraphNodeId] = useState(''); + const [selectedRoleId, setSelectedRoleId] = useState(""); + const [selectedStepId, setSelectedStepId] = useState(""); + const [selectedGraphNodeId, setSelectedGraphNodeId] = useState(""); const [graphFocusSequence, setGraphFocusSequence] = useState(0); const [isGraphFullscreenOpen, setIsGraphFullscreenOpen] = useState(false); const [viewportHeight, setViewportHeight] = useState(() => - typeof window === 'undefined' ? 960 : window.innerHeight, - ); - const [playgroundDraft, setPlaygroundDraft] = useState(() => - loadPlaygroundDraft(), + typeof window === "undefined" ? 960 : window.innerHeight ); const graphPanelRef = useRef(null); const pendingGraphScrollRef = useRef(false); const catalogQuery = useQuery({ - queryKey: ['workflow-catalog'], - queryFn: () => consoleApi.listWorkflowCatalog(), + queryKey: ["workflow-catalog"], + queryFn: () => runtimeCatalogApi.listWorkflowCatalog(), }); const detailQuery = useQuery({ - queryKey: ['workflow-detail', selectedWorkflow], + queryKey: ["workflow-detail", selectedWorkflow], enabled: Boolean(selectedWorkflow), - queryFn: () => consoleApi.getWorkflowDetail(selectedWorkflow), + queryFn: () => runtimeCatalogApi.getWorkflowDetail(selectedWorkflow), }); const workflowRows = useMemo( - () => buildWorkflowRows(listVisibleWorkflowCatalogItems(catalogQuery.data ?? [])), - [catalogQuery.data], + () => + buildWorkflowRows( + listVisibleWorkflowCatalogItems(catalogQuery.data ?? []) + ), + [catalogQuery.data] ); const filteredCatalog = useMemo( () => filterWorkflowRows(workflowRows, filters), - [filters, workflowRows], + [filters, workflowRows] ); const groupOptions = useMemo( () => buildStringOptions(workflowRows.map((item) => item.groupLabel)), - [workflowRows], + [workflowRows] ); const sourceOptions = useMemo( () => buildStringOptions(workflowRows.map((item) => item.sourceLabel)), - [workflowRows], + [workflowRows] ); const primitiveOptions = useMemo( () => buildStringOptions(workflowRows.flatMap((item) => item.primitives)), - [workflowRows], + [workflowRows] ); const advancedFilterCount = useMemo( () => countAdvancedFilters(filterDraft), - [filterDraft], + [filterDraft] ); const appliedFilterSummary = useMemo( () => summarizeAppliedFilters(filters), - [filters], + [filters] ); const hasPendingFilterChanges = useMemo( () => !areWorkflowFiltersEqual(filters, filterDraft), - [filterDraft, filters], + [filterDraft, filters] ); useEffect(() => { - if ((catalogQuery.data ?? []).some((item) => item.name === selectedWorkflow)) { + if ( + (catalogQuery.data ?? []).some((item) => item.name === selectedWorkflow) + ) { return; } const nextSelection = resolveWorkflowCatalogSelection( catalogQuery.data ?? [], - selectedWorkflow, + selectedWorkflow ); if (nextSelection !== selectedWorkflow) { setSelectedWorkflow(nextSelection); @@ -1120,70 +1063,51 @@ const WorkflowsPage: React.FC = () => { }, [catalogQuery.data, selectedWorkflow]); useEffect(() => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return; } const handleResize = () => setViewportHeight(window.innerHeight); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); }, []); useEffect(() => { - setSelectedRoleId(''); - setSelectedStepId(''); - setSelectedGraphNodeId(''); + setSelectedRoleId(""); + setSelectedStepId(""); + setSelectedGraphNodeId(""); setIsGraphFullscreenOpen(false); - setActiveDetailTab((current) => (current === 'graph' ? current : 'yaml')); + setActiveDetailTab((current) => (current === "graph" ? current : "yaml")); }, [selectedWorkflow]); useEffect(() => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return; } const url = new URL(window.location.href); if (selectedWorkflow) { - url.searchParams.set('workflow', selectedWorkflow); + url.searchParams.set("workflow", selectedWorkflow); } else { - url.searchParams.delete('workflow'); + url.searchParams.delete("workflow"); } - url.searchParams.set('tab', activeDetailTab); - window.history.replaceState(null, '', `${url.pathname}${url.search}`); + url.searchParams.set("tab", activeDetailTab); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); }, [activeDetailTab, selectedWorkflow]); - useEffect(() => { - const handleDraftUpdate = () => setPlaygroundDraft(loadPlaygroundDraft()); - window.addEventListener(PLAYGROUND_DRAFT_UPDATED_EVENT, handleDraftUpdate); - return () => - window.removeEventListener( - PLAYGROUND_DRAFT_UPDATED_EVENT, - handleDraftUpdate, - ); - }, []); - const workflowColumns = useMemo( () => createWorkflowColumns( (workflowName) => setSelectedWorkflow(workflowName), (workflowName) => - history.push(`/runs?workflow=${encodeURIComponent(workflowName)}`), - (workflowName) => - history.push(buildYamlBrowserRoute({ workflow: workflowName })), - (workflowName) => - history.push( - buildPlaygroundRoute({ - template: workflowName, - importTemplate: true, - }), - ), + history.push(`/runs?workflow=${encodeURIComponent(workflowName)}`) ), - [], + [] ); const stepRows = useMemo( () => buildStepRows(detailQuery.data?.definition.steps ?? []), - [detailQuery.data?.definition.steps], + [detailQuery.data?.definition.steps] ); const openRunsPage = useCallback(() => { @@ -1201,8 +1125,8 @@ const WorkflowsPage: React.FC = () => { return { closedWorldStatus: detailQuery.data.definition.closedWorldMode - ? 'success' - : 'default', + ? "success" + : "default", roleCount: detailQuery.data.definition.roles.length, stepCount: detailQuery.data.definition.steps.length, edgeCount: detailQuery.data.edges.length, @@ -1215,50 +1139,41 @@ const WorkflowsPage: React.FC = () => { workflowSummary ? [ { - id: 'mode', - title: 'Mode', + id: "mode", + title: "Mode", value: - workflowSummary.closedWorldStatus === 'success' - ? 'Closed world' - : 'Open world', + workflowSummary.closedWorldStatus === "success" + ? "Closed world" + : "Open world", }, { - id: 'source', - title: 'Source', + id: "source", + title: "Source", value: workflowSummary.sourceLabel, }, { - id: 'roles', - title: 'Roles', + id: "roles", + title: "Roles", value: workflowSummary.roleCount, }, { - id: 'steps', - title: 'Steps', + id: "steps", + title: "Steps", value: workflowSummary.stepCount, }, { - id: 'edges', - title: 'Edges', + id: "edges", + title: "Edges", value: workflowSummary.edgeCount, }, { - id: 'primitives', - title: 'Primitives', + id: "primitives", + title: "Primitives", value: workflowSummary.primitiveCount, }, ] : [], - [workflowSummary], - ); - - const playgroundDraftStatus = useMemo( - () => - getPlaygroundDraftStatus(playgroundDraft, { - referenceWorkflow: detailQuery.data?.catalog.name, - referenceYaml: detailQuery.data?.yaml, - }), - [detailQuery.data?.catalog.name, detailQuery.data?.yaml, playgroundDraft], + [workflowSummary] ); const graphElements = useMemo(() => { @@ -1269,51 +1184,51 @@ const WorkflowsPage: React.FC = () => { return buildWorkflowGraphElements(detailQuery.data); }, [detailQuery.data]); const graphTabLabel = useMemo(() => { - const isActive = activeDetailTab === 'graph'; + const isActive = activeDetailTab === "graph"; const hasFocus = Boolean(selectedGraphNodeId); const isHighlighted = isActive || hasFocus; return ( Graph { }, [activeDetailTab, selectedGraphNodeId]); const fullscreenGraphHeight = useMemo( () => Math.max(viewportHeight - 220, 560), - [viewportHeight], + [viewportHeight] ); const focusedRole = useMemo( () => detailQuery.data?.definition.roles.find( - (role) => role.id === selectedRoleId, + (role) => role.id === selectedRoleId ), - [detailQuery.data?.definition.roles, selectedRoleId], + [detailQuery.data?.definition.roles, selectedRoleId] ); const focusedStep = useMemo( () => stepRows.find((step) => step.id === selectedStepId), - [selectedStepId, stepRows], + [selectedStepId, stepRows] ); const focusRecord = useMemo(() => { if (focusedStep) { return { - focusType: 'step', + focusType: "step", focusId: focusedStep.id, - relatedRole: focusedStep.targetRole || '', + relatedRole: focusedStep.targetRole || "", relatedStepCount: 1, graphNodeId: focusedStep.id, }; @@ -1352,10 +1267,10 @@ const WorkflowsPage: React.FC = () => { if (focusedRole) { const relatedSteps = stepRows.filter( - (step) => step.targetRole === focusedRole.id, + (step) => step.targetRole === focusedRole.id ); return { - focusType: 'role', + focusType: "role", focusId: focusedRole.id, relatedRole: focusedRole.name || focusedRole.id, relatedStepCount: relatedSteps.length, @@ -1368,7 +1283,7 @@ const WorkflowsPage: React.FC = () => { const handleSelectRole = useCallback((roleId: string) => { setSelectedRoleId(roleId); - setSelectedStepId(''); + setSelectedStepId(""); }, []); const handleSelectStep = useCallback( @@ -1376,19 +1291,19 @@ const WorkflowsPage: React.FC = () => { setSelectedStepId(stepId); setSelectedRoleId(findWorkflowStepTargetRole(stepRows, stepId)); }, - [stepRows], + [stepRows] ); const handleRoleStepsFocus = useCallback((role: WorkflowCatalogRole) => { setSelectedRoleId(role.id); - setSelectedStepId(''); - setActiveDetailTab('steps'); + setSelectedStepId(""); + setActiveDetailTab("steps"); }, []); const handleInspectRoleFromStep = useCallback((roleId: string) => { setSelectedRoleId(roleId); - setSelectedStepId(''); - setActiveDetailTab('roles'); + setSelectedStepId(""); + setActiveDetailTab("roles"); }, []); const scrollGraphPanelIntoView = useCallback(() => { @@ -1398,8 +1313,8 @@ const WorkflowsPage: React.FC = () => { } scrollTarget.scrollIntoView?.({ - behavior: 'smooth', - block: 'nearest', + behavior: "smooth", + block: "nearest", }); pendingGraphScrollRef.current = false; return true; @@ -1413,15 +1328,15 @@ const WorkflowsPage: React.FC = () => { scrollGraphPanelIntoView(); } }, - [scrollGraphPanelIntoView], + [scrollGraphPanelIntoView] ); const focusRoleGraph = useCallback((roleId: string) => { setSelectedRoleId(roleId); - setSelectedStepId(''); + setSelectedStepId(""); setSelectedGraphNodeId(`role:${roleId}`); pendingGraphScrollRef.current = true; - setActiveDetailTab('graph'); + setActiveDetailTab("graph"); setGraphFocusSequence((current) => current + 1); }, []); @@ -1429,14 +1344,14 @@ const WorkflowsPage: React.FC = () => { (stepId: string, targetRole?: string) => { setSelectedStepId(stepId); setSelectedRoleId( - targetRole || findWorkflowStepTargetRole(stepRows, stepId), + targetRole || findWorkflowStepTargetRole(stepRows, stepId) ); setSelectedGraphNodeId(stepId); pendingGraphScrollRef.current = true; - setActiveDetailTab('graph'); + setActiveDetailTab("graph"); setGraphFocusSequence((current) => current + 1); }, - [stepRows], + [stepRows] ); const graphInteractionValue = useMemo( @@ -1444,28 +1359,28 @@ const WorkflowsPage: React.FC = () => { focusRoleGraph, focusStepGraph, }), - [focusRoleGraph, focusStepGraph], + [focusRoleGraph, focusStepGraph] ); const handleGraphNodeSelect = useCallback( (nodeId: string) => { setSelectedGraphNodeId(nodeId); - if (nodeId.startsWith('role:')) { - const roleId = nodeId.slice('role:'.length); + if (nodeId.startsWith("role:")) { + const roleId = nodeId.slice("role:".length); setSelectedRoleId(roleId); - setSelectedStepId(''); + setSelectedStepId(""); return; } setSelectedStepId(nodeId); setSelectedRoleId(findWorkflowStepTargetRole(stepRows, nodeId)); }, - [stepRows], + [stepRows] ); useLayoutEffect(() => { if ( - activeDetailTab !== 'graph' || + activeDetailTab !== "graph" || graphFocusSequence === 0 || !pendingGraphScrollRef.current ) { @@ -1497,25 +1412,21 @@ const WorkflowsPage: React.FC = () => { return ( - + { } onClick={toggleLibraryCollapsed} > - {isLibraryCollapsed ? 'Expand' : 'Collapse'} + {isLibraryCollapsed ? "Expand" : "Collapse"} } > {isLibraryCollapsed ? (
- Library panel is collapsed. + + Library panel is collapsed. + - Reopen the panel to browse filters, catalog rows, and workflow actions. + Reopen the panel to browse filters, catalog rows, and + workflow actions.
@@ -1550,10 +1464,13 @@ const WorkflowsPage: React.FC = () => { Current selection - {selectedWorkflow || 'No workflow selected'} + {selectedWorkflow || "No workflow selected"}
@@ -1587,11 +1504,11 @@ const WorkflowsPage: React.FC = () => { } > {showAdvancedFilters - ? 'Hide advanced filters' + ? "Hide advanced filters" : `Advanced filters${ advancedFilterCount > 0 ? ` (${advancedFilterCount})` - : '' + : "" }`}
) : null} - {filteredCatalog.length} workflow(s) shown ·{' '} + {filteredCatalog.length} workflow(s) shown ·{" "} {appliedFilterSummary}
@@ -1692,7 +1609,7 @@ const WorkflowsPage: React.FC = () => { ) : null} @@ -1716,8 +1633,8 @@ const WorkflowsPage: React.FC = () => { })} rowClassName={(record) => record.name === selectedWorkflow - ? 'ant-table-row-selected' - : '' + ? "ant-table-row-selected" + : "" } locale={{ emptyText: ( @@ -1739,7 +1656,7 @@ const WorkflowsPage: React.FC = () => { style={stretchColumnStyle} > { ) : detailQuery.data ? ( @@ -1762,7 +1679,7 @@ const WorkflowsPage: React.FC = () => { {detailQuery.data.catalog.name} @@ -1784,7 +1701,7 @@ const WorkflowsPage: React.FC = () => { ))} {detailQuery.data.catalog.primitives.length > 3 ? ( - +{detailQuery.data.catalog.primitives.length - 3}{' '} + +{detailQuery.data.catalog.primitives.length - 3}{" "} more ) : null} @@ -1799,7 +1716,7 @@ const WorkflowsPage: React.FC = () => { }} > {detailQuery.data.catalog.description || - 'No description provided.'} + "No description provided."}
@@ -1807,37 +1724,16 @@ const WorkflowsPage: React.FC = () => { type="primary" onClick={() => history.push( - `/runs?workflow=${encodeURIComponent(detailQuery.data.catalog.name)}`, + `/runs?workflow=${encodeURIComponent( + detailQuery.data.catalog.name + )}` ) } > Run workflow - -
@@ -1871,13 +1767,6 @@ const WorkflowsPage: React.FC = () => {
) : null} - - @@ -1888,14 +1777,14 @@ const WorkflowsPage: React.FC = () => { } items={[ { - key: 'yaml', - label: 'YAML', + key: "yaml", + label: "YAML", children: ( ), }, { - key: 'roles', + key: "roles", label: `Roles (${detailQuery.data.definition.roles.length})`, children: ( { ), }, { - key: 'steps', + key: "steps", label: `Steps (${detailQuery.data.definition.steps.length})`, children: ( { ), }, { - key: 'graph', + key: "graph", label: graphTabLabel, children: (
{ Node highlights follow the latest focus action from Roles or Steps. @@ -1975,7 +1864,7 @@ const WorkflowsPage: React.FC = () => { footer={null} onCancel={() => setIsGraphFullscreenOpen(false)} open={isGraphFullscreenOpen} - style={{ maxWidth: '100vw', paddingBottom: 0, top: 0 }} + style={{ maxWidth: "100vw", paddingBottom: 0, top: 0 }} styles={{ body: { flex: 1, @@ -1984,9 +1873,9 @@ const WorkflowsPage: React.FC = () => { }, container: { borderRadius: 0, - display: 'flex', - flexDirection: 'column', - height: '100vh', + display: "flex", + flexDirection: "column", + height: "100vh", padding: 24, }, header: { @@ -2004,7 +1893,7 @@ const WorkflowsPage: React.FC = () => { Explore the workflow topology with more canvas space while keeping node focus in sync with the detail @@ -2042,7 +1931,7 @@ const WorkflowsPage: React.FC = () => { ) : ( )} diff --git a/apps/aevatar-console-web/src/pages/workflows/workflowPresentation.ts b/apps/aevatar-console-web/src/pages/workflows/workflowPresentation.ts index bdf333f9..9be97498 100644 --- a/apps/aevatar-console-web/src/pages/workflows/workflowPresentation.ts +++ b/apps/aevatar-console-web/src/pages/workflows/workflowPresentation.ts @@ -1,15 +1,18 @@ -import type { WorkflowCatalogItem, WorkflowCatalogStep } from '@/shared/api/models'; +import type { + WorkflowCatalogItem, + WorkflowCatalogStep, +} from "@/shared/models/runtime/catalog"; export type WorkflowLibraryFilter = { keyword: string; groups: string[]; sources: string[]; - llmRequirement: 'all' | 'required' | 'optional'; + llmRequirement: "all" | "required" | "optional"; primitives: string[]; }; export type WorkflowLibraryRow = WorkflowCatalogItem & { - llmStatus: 'processing' | 'success'; + llmStatus: "processing" | "success"; primitiveSummary: string; searchText: string; }; @@ -22,21 +25,21 @@ export type WorkflowStepRow = WorkflowCatalogStep & { }; export const defaultWorkflowLibraryFilter: WorkflowLibraryFilter = { - keyword: '', + keyword: "", groups: [], sources: [], - llmRequirement: 'all', + llmRequirement: "all", primitives: [], }; export function buildWorkflowRows( - items: WorkflowCatalogItem[], + items: WorkflowCatalogItem[] ): WorkflowLibraryRow[] { return items.map((item) => ({ ...item, - llmStatus: item.requiresLlmProvider ? 'processing' : 'success', + llmStatus: item.requiresLlmProvider ? "processing" : "success", primitiveSummary: - item.primitives.length > 0 ? item.primitives.join(', ') : 'n/a', + item.primitives.length > 0 ? item.primitives.join(", ") : "n/a", searchText: [ item.name, item.description, @@ -45,16 +48,16 @@ export function buildWorkflowRows( item.category, item.source, item.sourceLabel, - item.primitives.join(' '), + item.primitives.join(" "), ] - .join(' ') + .join(" ") .toLowerCase(), })); } export function filterWorkflowRows( rows: WorkflowLibraryRow[], - filters: WorkflowLibraryFilter, + filters: WorkflowLibraryFilter ): WorkflowLibraryRow[] { const keyword = filters.keyword.trim().toLowerCase(); @@ -63,27 +66,26 @@ export function filterWorkflowRows( return false; } - if (filters.sources.length > 0 && !filters.sources.includes(row.sourceLabel)) { + if ( + filters.sources.length > 0 && + !filters.sources.includes(row.sourceLabel) + ) { return false; } - if ( - filters.llmRequirement === 'required' && - !row.requiresLlmProvider - ) { + if (filters.llmRequirement === "required" && !row.requiresLlmProvider) { return false; } - if ( - filters.llmRequirement === 'optional' && - row.requiresLlmProvider - ) { + if (filters.llmRequirement === "optional" && row.requiresLlmProvider) { return false; } if ( filters.primitives.length > 0 && - !filters.primitives.every((primitive) => row.primitives.includes(primitive)) + !filters.primitives.every((primitive) => + row.primitives.includes(primitive) + ) ) { return false; } @@ -106,7 +108,9 @@ export function buildStepRows(steps: WorkflowCatalogStep[]): WorkflowStepRow[] { })); } -export function buildStringOptions(values: string[]): Array<{ label: string; value: string }> { +export function buildStringOptions( + values: string[] +): Array<{ label: string; value: string }> { return Array.from(new Set(values.filter(Boolean))) .sort((left, right) => left.localeCompare(right)) .map((value) => ({ label: value, value })); @@ -114,7 +118,7 @@ export function buildStringOptions(values: string[]): Array<{ label: string; val export function findWorkflowStepTargetRole( rows: WorkflowStepRow[], - stepId: string, + stepId: string ): string { - return rows.find((row) => row.id === stepId)?.targetRole ?? ''; + return rows.find((row) => row.id === stepId)?.targetRole ?? ""; } diff --git a/apps/aevatar-console-web/src/pages/yaml/index.test.tsx b/apps/aevatar-console-web/src/pages/yaml/index.test.tsx deleted file mode 100644 index 5daafeb1..00000000 --- a/apps/aevatar-console-web/src/pages/yaml/index.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import React from 'react'; -import { savePlaygroundDraft } from '@/shared/playground/playgroundDraft'; -import { renderWithQueryClient } from '../../../tests/reactQueryTestUtils'; -import YamlPage from './index'; - -const mockReplace = jest.fn(); -const mockPush = jest.fn(); - -jest.mock('@umijs/max', () => ({ - history: { - replace: (...args: unknown[]) => mockReplace(...args), - push: (...args: unknown[]) => mockPush(...args), - }, -})); - -describe('YamlPage', () => { - beforeEach(() => { - window.localStorage.clear(); - window.history.pushState({}, '', '/yaml'); - mockReplace.mockReset(); - mockPush.mockReset(); - }); - - it('redirects workflow YAML routes into the Studio editor', async () => { - window.history.pushState({}, '', '/yaml?workflow=demo_template'); - - renderWithQueryClient(React.createElement(YamlPage)); - - expect(await screen.findByText('Opening Studio')).toBeTruthy(); - - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith( - '/studio?template=demo_template&tab=studio', - ); - }); - }); - - it('redirects legacy playground YAML inspection into a Studio draft handoff', async () => { - savePlaygroundDraft({ - yaml: 'name: local_draft\nsteps: []\n', - prompt: 'Use the local draft instead.', - sourceWorkflow: 'demo_template', - }); - window.history.pushState( - {}, - '', - '/yaml?workflow=demo_template&source=playground', - ); - - renderWithQueryClient(React.createElement(YamlPage)); - - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith( - '/studio?tab=studio&draft=new&prompt=Use+the+local+draft+instead.&legacy=playground', - ); - }); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/yaml/index.tsx b/apps/aevatar-console-web/src/pages/yaml/index.tsx deleted file mode 100644 index 665195b0..00000000 --- a/apps/aevatar-console-web/src/pages/yaml/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { PageContainer, ProCard } from '@ant-design/pro-components'; -import { history } from '@umijs/max'; -import { Alert, Button, Space } from 'antd'; -import React, { useEffect, useMemo } from 'react'; -import { loadPlaygroundDraft } from '@/shared/playground/playgroundDraft'; -import { buildStudioRoute } from '@/shared/studio/navigation'; -import { - cardStackStyle, - fillCardStyle, - moduleCardProps, -} from '@/shared/ui/proComponents'; - -type LegacyYamlSource = 'workflow' | 'playground'; - -function trimOptional(value: string | null): string { - return value?.trim() ?? ''; -} - -function parseLegacyYamlSource(value: string | null): LegacyYamlSource { - return value === 'playground' ? 'playground' : 'workflow'; -} - -function buildLegacyYamlRedirectTarget(): string { - if (typeof window === 'undefined') { - return buildStudioRoute({ draftMode: 'new', tab: 'studio' }); - } - - const draft = loadPlaygroundDraft(); - const params = new URLSearchParams(window.location.search); - const source = parseLegacyYamlSource(params.get('source')); - const workflow = trimOptional(params.get('workflow')); - const prompt = draft.prompt; - - if (source === 'playground' && draft.yaml.trim()) { - return buildStudioRoute({ - draftMode: 'new', - tab: 'studio', - prompt, - legacySource: 'playground', - }); - } - - if (workflow) { - return buildStudioRoute({ - template: workflow, - tab: 'studio', - prompt, - }); - } - - if (draft.yaml.trim()) { - return buildStudioRoute({ - draftMode: 'new', - tab: 'studio', - prompt, - legacySource: 'playground', - }); - } - - if (draft.sourceWorkflow) { - return buildStudioRoute({ - template: draft.sourceWorkflow, - tab: 'studio', - prompt, - }); - } - - return buildStudioRoute({ draftMode: 'new', tab: 'studio', prompt }); -} - -const YamlPage: React.FC = () => { - const redirectTarget = useMemo(buildLegacyYamlRedirectTarget, []); - - useEffect(() => { - history.replace(redirectTarget); - }, [redirectTarget]); - - return ( - - -
- - - - - -
-
-
- ); -}; - -export default YamlPage; diff --git a/apps/aevatar-console-web/src/shared/api/configurationApi.ts b/apps/aevatar-console-web/src/shared/api/configurationApi.ts index 32c2b1e9..aa263973 100644 --- a/apps/aevatar-console-web/src/shared/api/configurationApi.ts +++ b/apps/aevatar-console-web/src/shared/api/configurationApi.ts @@ -2,23 +2,23 @@ import type { ConfigurationCollectionRawDocument, ConfigurationEmbeddingsStatus, ConfigurationLlmApiKeyStatus, - ConfigurationSecretValueStatus, - ConfigurationSecp256k1GenerateResult, - ConfigurationSecp256k1Status, - ConfigurationSkillsMpStatus, - ConfigurationMcpServer, ConfigurationLlmProbeResult, ConfigurationLlmInstance, ConfigurationLlmProviderType, + ConfigurationMcpServer, ConfigurationRawDocument, + ConfigurationSecretValueStatus, + ConfigurationSecp256k1GenerateResult, + ConfigurationSecp256k1Status, + ConfigurationSkillsMpStatus, ConfigurationSourceStatus, ConfigurationValidationResult, ConfigurationWebSearchStatus, ConfigurationWorkflowFile, ConfigurationWorkflowFileDetail, -} from './models'; +} from "@/shared/models/platform/configuration"; +import type { Decoder } from "./decodeUtils"; import { - type Decoder, decodeConfigurationEmbeddingsStatusResponse, decodeConfigurationCollectionRawDocumentResponse, decodeConfigurationLlmApiKeyStatusResponse, @@ -39,21 +39,21 @@ import { decodeConfigurationWorkflowFileDetailResponse, decodeConfigurationWorkflowFileMutationResponse, decodeConfigurationWorkflowFilesResponse, -} from './decoders'; -import { authFetch } from '@/shared/auth/fetch'; +} from "./configurationDecoders"; +import { authFetch } from "@/shared/auth/fetch"; const JSON_HEADERS = { - 'Content-Type': 'application/json', - Accept: 'application/json', + "Content-Type": "application/json", + Accept: "application/json", }; -const CONFIGURATION_API_PREFIX = '/api/configuration'; +const CONFIGURATION_API_PREFIX = "/api/configuration"; -type WorkflowSource = 'home' | 'repo' | 'all'; +type WorkflowSource = "home" | "repo" | "all"; function compactObject>(value: T): T { return Object.fromEntries( - Object.entries(value).filter(([, entry]) => entry !== undefined), + Object.entries(value).filter(([, entry]) => entry !== undefined) ) as T; } @@ -83,7 +83,7 @@ async function readError(response: Response): Promise { async function requestJson( input: string, decoder: Decoder, - init?: RequestInit, + init?: RequestInit ): Promise { const response = await authFetch(input, init); if (!response.ok) { @@ -112,76 +112,84 @@ function withSource(path: string, source?: WorkflowSource): string { } export const configurationApi = { - async getHealth(): Promise<'ok'> { + async getHealth(): Promise<"ok"> { const payload = await requestText(`${CONFIGURATION_API_PREFIX}/health`); - if (payload.trim() !== 'ok') { - throw new Error(`Unexpected configuration health payload: ${payload || ''}`); + if (payload.trim() !== "ok") { + throw new Error( + `Unexpected configuration health payload: ${payload || ""}` + ); } - return 'ok'; + return "ok"; }, getSourceStatus(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/source`, - decodeConfigurationSourceStatusResponse, + decodeConfigurationSourceStatusResponse ); }, - listWorkflows(source: WorkflowSource = 'all'): Promise { + listWorkflows( + source: WorkflowSource = "all" + ): Promise { return requestJson( withSource(`${CONFIGURATION_API_PREFIX}/workflows`, source), - decodeConfigurationWorkflowFilesResponse, + decodeConfigurationWorkflowFilesResponse ); }, getWorkflow( filename: string, - source: WorkflowSource = 'all', + source: WorkflowSource = "all" ): Promise { return requestJson( withSource( `${CONFIGURATION_API_PREFIX}/workflows/${encodeURIComponent(filename)}`, - source, + source ), - decodeConfigurationWorkflowFileDetailResponse, + decodeConfigurationWorkflowFileDetailResponse ); }, saveWorkflow(input: { filename: string; content: string; - source: Exclude; + source: Exclude; }): Promise { return requestJson( withSource( - `${CONFIGURATION_API_PREFIX}/workflows/${encodeURIComponent(input.filename)}`, - input.source, + `${CONFIGURATION_API_PREFIX}/workflows/${encodeURIComponent( + input.filename + )}`, + input.source ), decodeConfigurationWorkflowFileMutationResponse, { - method: 'PUT', + method: "PUT", headers: JSON_HEADERS, body: JSON.stringify({ content: input.content, }), - }, + } ); }, async deleteWorkflow(input: { filename: string; - source: Exclude; + source: Exclude; }): Promise { const response = await authFetch( withSource( - `${CONFIGURATION_API_PREFIX}/workflows/${encodeURIComponent(input.filename)}`, - input.source, + `${CONFIGURATION_API_PREFIX}/workflows/${encodeURIComponent( + input.filename + )}`, + input.source ), { - method: 'DELETE', + method: "DELETE", headers: JSON_HEADERS, - }, + } ); if (!response.ok) { throw new Error(await readError(response)); @@ -191,13 +199,13 @@ export const configurationApi = { getConfigRaw(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/config/raw`, - decodeConfigurationRawDocumentResponse, + decodeConfigurationRawDocumentResponse ); }, async saveConfigRaw(json: string): Promise { const response = await authFetch(`${CONFIGURATION_API_PREFIX}/config/raw`, { - method: 'PUT', + method: "PUT", headers: JSON_HEADERS, body: JSON.stringify({ json, @@ -211,18 +219,21 @@ export const configurationApi = { getConnectorsRaw(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/connectors/raw`, - decodeConfigurationCollectionRawDocumentResponse, + decodeConfigurationCollectionRawDocumentResponse ); }, async saveConnectorsRaw(json: string): Promise { - const response = await authFetch(`${CONFIGURATION_API_PREFIX}/connectors/raw`, { - method: 'PUT', - headers: JSON_HEADERS, - body: JSON.stringify({ - json, - }), - }); + const response = await authFetch( + `${CONFIGURATION_API_PREFIX}/connectors/raw`, + { + method: "PUT", + headers: JSON_HEADERS, + body: JSON.stringify({ + json, + }), + } + ); if (!response.ok) { throw new Error(await readError(response)); } @@ -233,19 +244,19 @@ export const configurationApi = { `${CONFIGURATION_API_PREFIX}/connectors/validate`, decodeConfigurationValidationResultResponse, { - method: 'POST', + method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ json, }), - }, + } ); }, listMcpServers(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/mcp`, - decodeConfigurationMcpServersResponse, + decodeConfigurationMcpServersResponse ); }, @@ -260,7 +271,7 @@ export const configurationApi = { `${CONFIGURATION_API_PREFIX}/mcp/${encodeURIComponent(input.name)}`, decodeConfigurationMcpServerMutationResponse, { - method: 'PUT', + method: "PUT", headers: JSON_HEADERS, body: JSON.stringify({ command: input.command, @@ -268,7 +279,7 @@ export const configurationApi = { env: input.env, timeoutMs: input.timeoutMs, }), - }, + } ); }, @@ -276,9 +287,9 @@ export const configurationApi = { const response = await authFetch( `${CONFIGURATION_API_PREFIX}/mcp/${encodeURIComponent(name)}`, { - method: 'DELETE', + method: "DELETE", headers: JSON_HEADERS, - }, + } ); if (!response.ok) { throw new Error(await readError(response)); @@ -288,13 +299,13 @@ export const configurationApi = { getMcpRaw(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/mcp/raw`, - decodeConfigurationCollectionRawDocumentResponse, + decodeConfigurationCollectionRawDocumentResponse ); }, async saveMcpRaw(json: string): Promise { const response = await authFetch(`${CONFIGURATION_API_PREFIX}/mcp/raw`, { - method: 'PUT', + method: "PUT", headers: JSON_HEADERS, body: JSON.stringify({ json, @@ -310,34 +321,34 @@ export const configurationApi = { `${CONFIGURATION_API_PREFIX}/mcp/validate`, decodeConfigurationValidationResultResponse, { - method: 'POST', + method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ json, }), - }, + } ); }, getEmbeddingsStatus(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/embeddings`, - decodeConfigurationEmbeddingsStatusResponse, + decodeConfigurationEmbeddingsStatusResponse ); }, getEmbeddingsApiKey( - input: { reveal?: boolean } = {}, + input: { reveal?: boolean } = {} ): Promise { const params = new URLSearchParams(); if (input.reveal === true) { - params.set('reveal', 'true'); + params.set("reveal", "true"); } - const suffix = params.size > 0 ? `?${params.toString()}` : ''; + const suffix = params.size > 0 ? `?${params.toString()}` : ""; return requestJson( `${CONFIGURATION_API_PREFIX}/embeddings/api-key${suffix}`, - decodeConfigurationSecretValueStatusResponse, + decodeConfigurationSecretValueStatusResponse ); }, @@ -349,7 +360,7 @@ export const configurationApi = { apiKey?: string; }): Promise { const response = await authFetch(`${CONFIGURATION_API_PREFIX}/embeddings`, { - method: 'POST', + method: "POST", headers: JSON_HEADERS, body: JSON.stringify(compactObject(input)), }); @@ -360,7 +371,7 @@ export const configurationApi = { async deleteEmbeddings(): Promise { const response = await authFetch(`${CONFIGURATION_API_PREFIX}/embeddings`, { - method: 'DELETE', + method: "DELETE", headers: JSON_HEADERS, }); if (!response.ok) { @@ -371,22 +382,22 @@ export const configurationApi = { getWebSearchStatus(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/websearch`, - decodeConfigurationWebSearchStatusResponse, + decodeConfigurationWebSearchStatusResponse ); }, getWebSearchApiKey( - input: { reveal?: boolean } = {}, + input: { reveal?: boolean } = {} ): Promise { const params = new URLSearchParams(); if (input.reveal === true) { - params.set('reveal', 'true'); + params.set("reveal", "true"); } - const suffix = params.size > 0 ? `?${params.toString()}` : ''; + const suffix = params.size > 0 ? `?${params.toString()}` : ""; return requestJson( `${CONFIGURATION_API_PREFIX}/websearch/api-key${suffix}`, - decodeConfigurationSecretValueStatusResponse, + decodeConfigurationSecretValueStatusResponse ); }, @@ -399,7 +410,7 @@ export const configurationApi = { apiKey?: string; }): Promise { const response = await authFetch(`${CONFIGURATION_API_PREFIX}/websearch`, { - method: 'POST', + method: "POST", headers: JSON_HEADERS, body: JSON.stringify(compactObject(input)), }); @@ -410,7 +421,7 @@ export const configurationApi = { async deleteWebSearch(): Promise { const response = await authFetch(`${CONFIGURATION_API_PREFIX}/websearch`, { - method: 'DELETE', + method: "DELETE", headers: JSON_HEADERS, }); if (!response.ok) { @@ -421,22 +432,22 @@ export const configurationApi = { getSkillsMpStatus(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/skillsmp/status`, - decodeConfigurationSkillsMpStatusResponse, + decodeConfigurationSkillsMpStatusResponse ); }, getSkillsMpApiKey( - input: { reveal?: boolean } = {}, + input: { reveal?: boolean } = {} ): Promise { const params = new URLSearchParams(); if (input.reveal === true) { - params.set('reveal', 'true'); + params.set("reveal", "true"); } - const suffix = params.size > 0 ? `?${params.toString()}` : ''; + const suffix = params.size > 0 ? `?${params.toString()}` : ""; return requestJson( `${CONFIGURATION_API_PREFIX}/skillsmp/api-key${suffix}`, - decodeConfigurationSecretValueStatusResponse, + decodeConfigurationSecretValueStatusResponse ); }, @@ -445,7 +456,7 @@ export const configurationApi = { baseUrl?: string; }): Promise { const response = await authFetch(`${CONFIGURATION_API_PREFIX}/skillsmp`, { - method: 'POST', + method: "POST", headers: JSON_HEADERS, body: JSON.stringify(compactObject(input)), }); @@ -456,7 +467,7 @@ export const configurationApi = { async deleteSkillsMp(): Promise { const response = await authFetch(`${CONFIGURATION_API_PREFIX}/skillsmp`, { - method: 'DELETE', + method: "DELETE", headers: JSON_HEADERS, }); if (!response.ok) { @@ -467,7 +478,7 @@ export const configurationApi = { getSecp256k1Status(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/crypto/secp256k1/status`, - decodeConfigurationSecp256k1StatusResponse, + decodeConfigurationSecp256k1StatusResponse ); }, @@ -476,27 +487,30 @@ export const configurationApi = { `${CONFIGURATION_API_PREFIX}/crypto/secp256k1/generate`, decodeConfigurationSecp256k1GenerateResponse, { - method: 'POST', + method: "POST", headers: JSON_HEADERS, - }, + } ); }, getSecretsRaw(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/secrets/raw`, - decodeConfigurationRawDocumentResponse, + decodeConfigurationRawDocumentResponse ); }, async saveSecretsRaw(json: string): Promise { - const response = await authFetch(`${CONFIGURATION_API_PREFIX}/secrets/raw`, { - method: 'PUT', - headers: JSON_HEADERS, - body: JSON.stringify({ - json, - }), - }); + const response = await authFetch( + `${CONFIGURATION_API_PREFIX}/secrets/raw`, + { + method: "PUT", + headers: JSON_HEADERS, + body: JSON.stringify({ + json, + }), + } + ); if (!response.ok) { throw new Error(await readError(response)); } @@ -505,21 +519,21 @@ export const configurationApi = { listLlmProviders(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/llm/providers`, - decodeConfigurationLlmProviderTypesResponse, + decodeConfigurationLlmProviderTypesResponse ); }, listLlmInstances(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/llm/instances`, - decodeConfigurationLlmInstancesResponse, + decodeConfigurationLlmInstancesResponse ); }, getLlmDefault(): Promise { return requestJson( `${CONFIGURATION_API_PREFIX}/llm/default`, - decodeConfigurationLlmDefaultResponse, + decodeConfigurationLlmDefaultResponse ); }, @@ -528,30 +542,32 @@ export const configurationApi = { `${CONFIGURATION_API_PREFIX}/llm/default`, decodeConfigurationLlmDefaultResponse, { - method: 'POST', + method: "POST", headers: JSON_HEADERS, body: JSON.stringify( compactObject({ providerName: trimOptional(providerName), - }), + }) ), - }, + } ); }, getLlmApiKey( providerName: string, - input: { reveal?: boolean } = {}, + input: { reveal?: boolean } = {} ): Promise { const params = new URLSearchParams(); if (input.reveal === true) { - params.set('reveal', 'true'); + params.set("reveal", "true"); } - const suffix = params.size > 0 ? `?${params.toString()}` : ''; + const suffix = params.size > 0 ? `?${params.toString()}` : ""; return requestJson( - `${CONFIGURATION_API_PREFIX}/llm/api-key/${encodeURIComponent(providerName)}${suffix}`, - decodeConfigurationLlmApiKeyStatusResponse, + `${CONFIGURATION_API_PREFIX}/llm/api-key/${encodeURIComponent( + providerName + )}${suffix}`, + decodeConfigurationLlmApiKeyStatusResponse ); }, @@ -559,14 +575,17 @@ export const configurationApi = { providerName: string; apiKey: string; }): Promise { - const response = await authFetch(`${CONFIGURATION_API_PREFIX}/llm/api-key`, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify({ - providerName: input.providerName, - apiKey: input.apiKey, - }), - }); + const response = await authFetch( + `${CONFIGURATION_API_PREFIX}/llm/api-key`, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + providerName: input.providerName, + apiKey: input.apiKey, + }), + } + ); if (!response.ok) { throw new Error(await readError(response)); } @@ -574,11 +593,13 @@ export const configurationApi = { async deleteLlmApiKey(providerName: string): Promise { const response = await authFetch( - `${CONFIGURATION_API_PREFIX}/llm/api-key/${encodeURIComponent(providerName)}`, + `${CONFIGURATION_API_PREFIX}/llm/api-key/${encodeURIComponent( + providerName + )}`, { - method: 'DELETE', + method: "DELETE", headers: JSON_HEADERS, - }, + } ); if (!response.ok) { throw new Error(await readError(response)); @@ -594,11 +615,14 @@ export const configurationApi = { copyApiKeyFrom?: string; forceCopyApiKeyFrom?: boolean; }): Promise { - const response = await authFetch(`${CONFIGURATION_API_PREFIX}/llm/instance`, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify(compactObject(input)), - }); + const response = await authFetch( + `${CONFIGURATION_API_PREFIX}/llm/instance`, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(compactObject(input)), + } + ); if (!response.ok) { throw new Error(await readError(response)); } @@ -606,11 +630,13 @@ export const configurationApi = { async deleteLlmInstance(providerName: string): Promise { const response = await authFetch( - `${CONFIGURATION_API_PREFIX}/llm/instance/${encodeURIComponent(providerName)}`, + `${CONFIGURATION_API_PREFIX}/llm/instance/${encodeURIComponent( + providerName + )}`, { - method: 'DELETE', + method: "DELETE", headers: JSON_HEADERS, - }, + } ); if (!response.ok) { throw new Error(await readError(response)); @@ -626,10 +652,10 @@ export const configurationApi = { `${CONFIGURATION_API_PREFIX}/llm/probe/test`, decodeConfigurationLlmProbeResultResponse, { - method: 'POST', + method: "POST", headers: JSON_HEADERS, body: JSON.stringify(compactObject(input)), - }, + } ); }, @@ -642,30 +668,36 @@ export const configurationApi = { `${CONFIGURATION_API_PREFIX}/llm/probe/models`, decodeConfigurationLlmProbeResultResponse, { - method: 'POST', + method: "POST", headers: JSON_HEADERS, body: JSON.stringify(compactObject(input)), - }, + } ); }, async setSecret(input: { key: string; value: string }): Promise { - const response = await authFetch(`${CONFIGURATION_API_PREFIX}/secrets/set`, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify(input), - }); + const response = await authFetch( + `${CONFIGURATION_API_PREFIX}/secrets/set`, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(input), + } + ); if (!response.ok) { throw new Error(await readError(response)); } }, async removeSecret(key: string): Promise { - const response = await authFetch(`${CONFIGURATION_API_PREFIX}/secrets/remove`, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify({ key }), - }); + const response = await authFetch( + `${CONFIGURATION_API_PREFIX}/secrets/remove`, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ key }), + } + ); if (!response.ok) { throw new Error(await readError(response)); } diff --git a/apps/aevatar-console-web/src/shared/api/configurationDecoders.ts b/apps/aevatar-console-web/src/shared/api/configurationDecoders.ts new file mode 100644 index 00000000..6af74e8b --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/configurationDecoders.ts @@ -0,0 +1,556 @@ +import type { + ConfigurationCollectionRawDocument, + ConfigurationDoctorReport, + ConfigurationEmbeddingsStatus, + ConfigurationLlmApiKeyStatus, + ConfigurationLlmInstance, + ConfigurationLlmProbeResult, + ConfigurationLlmProviderType, + ConfigurationMcpServer, + ConfigurationPathInfo, + ConfigurationPathStatus, + ConfigurationRawDocument, + ConfigurationSecretValueStatus, + ConfigurationSecp256k1GenerateResult, + ConfigurationSecp256k1PrivateKeyStatus, + ConfigurationSecp256k1PublicKeyStatus, + ConfigurationSecp256k1Status, + ConfigurationSkillsMpStatus, + ConfigurationSourceStatus, + ConfigurationValidationResult, + ConfigurationWebSearchStatus, + ConfigurationWorkflowFile, + ConfigurationWorkflowFileDetail, +} from "@/shared/models/platform/configuration"; +import { + type Decoder, + expectArray, + expectBoolean, + expectNullableBoolean, + expectNullableNumber, + expectNumber, + expectOptionalBoolean, + expectOptionalNumber, + expectOptionalString, + expectRecord, + expectString, + expectStringArray, + expectStringRecord, +} from "./decodeUtils"; + +function decodeConfigurationPathInfo( + value: unknown, + label = "ConfigurationPathInfo" +): ConfigurationPathInfo { + const record = expectRecord(value, label); + const homeEnvValue = expectOptionalString( + record.homeEnvValue, + `${label}.homeEnvValue` + ); + const secretsPathEnvValue = expectOptionalString( + record.secretsPathEnvValue, + `${label}.secretsPathEnvValue` + ); + return { + root: expectString(record.root, `${label}.root`), + secretsJson: expectString(record.secretsJson, `${label}.secretsJson`), + configJson: expectString(record.configJson, `${label}.configJson`), + connectorsJson: expectString( + record.connectorsJson, + `${label}.connectorsJson` + ), + mcpJson: expectString(record.mcpJson, `${label}.mcpJson`), + workflowsHome: expectString(record.workflowsHome, `${label}.workflowsHome`), + workflowsRepo: expectString(record.workflowsRepo, `${label}.workflowsRepo`), + homeEnvValue: homeEnvValue ?? null, + secretsPathEnvValue: secretsPathEnvValue ?? null, + }; +} + +function decodeConfigurationPathStatus( + value: unknown, + label = "ConfigurationPathStatus" +): ConfigurationPathStatus { + const record = expectRecord(value, label); + const sizeBytes = expectOptionalNumber( + record.sizeBytes, + `${label}.sizeBytes` + ); + const error = expectOptionalString(record.error, `${label}.error`); + return { + path: expectString(record.path, `${label}.path`), + exists: expectBoolean(record.exists, `${label}.exists`), + readable: expectBoolean(record.readable, `${label}.readable`), + writable: expectBoolean(record.writable, `${label}.writable`), + sizeBytes: sizeBytes ?? null, + error: error ?? null, + }; +} + +function decodeConfigurationDoctorReport( + value: unknown, + label = "ConfigurationDoctorReport" +): ConfigurationDoctorReport { + const record = expectRecord(value, label); + return { + paths: decodeConfigurationPathInfo(record.paths, `${label}.paths`), + secrets: decodeConfigurationPathStatus(record.secrets, `${label}.secrets`), + config: decodeConfigurationPathStatus(record.config, `${label}.config`), + connectors: decodeConfigurationPathStatus( + record.connectors, + `${label}.connectors` + ), + mcp: decodeConfigurationPathStatus(record.mcp, `${label}.mcp`), + workflowsHome: decodeConfigurationPathStatus( + record.workflowsHome, + `${label}.workflowsHome` + ), + workflowsRepo: decodeConfigurationPathStatus( + record.workflowsRepo, + `${label}.workflowsRepo` + ), + }; +} + +function decodeConfigurationSourceStatus( + value: unknown, + label = "ConfigurationSourceStatus" +): ConfigurationSourceStatus { + const record = expectRecord(value, label); + return { + mode: expectString(record.mode, `${label}.mode`), + mongoConfigured: expectBoolean( + record.mongoConfigured, + `${label}.mongoConfigured` + ), + fileConfigured: expectBoolean( + record.fileConfigured, + `${label}.fileConfigured` + ), + localRuntimeAccess: expectBoolean( + record.localRuntimeAccess, + `${label}.localRuntimeAccess` + ), + paths: decodeConfigurationPathInfo(record.paths, `${label}.paths`), + doctor: decodeConfigurationDoctorReport(record.doctor, `${label}.doctor`), + }; +} + +function decodeConfigurationWorkflowFile( + value: unknown, + label = "ConfigurationWorkflowFile" +): ConfigurationWorkflowFile { + const record = expectRecord(value, label); + return { + filename: expectString(record.filename, `${label}.filename`), + source: expectString(record.source, `${label}.source`), + path: expectString(record.path, `${label}.path`), + sizeBytes: expectNumber(record.sizeBytes, `${label}.sizeBytes`), + lastModified: expectString(record.lastModified, `${label}.lastModified`), + }; +} + +function decodeConfigurationWorkflowFileDetail( + value: unknown, + label = "ConfigurationWorkflowFileDetail" +): ConfigurationWorkflowFileDetail { + const record = expectRecord(value, label); + return { + ...decodeConfigurationWorkflowFile(value, label), + content: expectString(record.content, `${label}.content`), + }; +} + +function decodeConfigurationRawDocument( + value: unknown, + label = "ConfigurationRawDocument" +): ConfigurationRawDocument { + const record = expectRecord(value, label); + return { + json: expectString(record.json, `${label}.json`), + keyCount: expectNumber(record.keyCount, `${label}.keyCount`), + exists: expectOptionalBoolean(record.exists, `${label}.exists`), + path: expectOptionalString(record.path, `${label}.path`), + }; +} + +function decodeConfigurationCollectionRawDocument( + value: unknown, + label = "ConfigurationCollectionRawDocument" +): ConfigurationCollectionRawDocument { + const record = expectRecord(value, label); + return { + json: expectString(record.json, `${label}.json`), + count: expectNumber(record.count, `${label}.count`), + exists: expectOptionalBoolean(record.exists, `${label}.exists`), + path: expectOptionalString(record.path, `${label}.path`), + }; +} + +function decodeConfigurationValidationResult( + value: unknown, + label = "ConfigurationValidationResult" +): ConfigurationValidationResult { + const record = expectRecord(value, label); + return { + valid: expectBoolean(record.valid, `${label}.valid`), + message: expectString(record.message, `${label}.message`), + count: expectNumber(record.count, `${label}.count`), + }; +} + +function decodeConfigurationMcpServer( + value: unknown, + label = "ConfigurationMcpServer" +): ConfigurationMcpServer { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + command: expectString(record.command, `${label}.command`), + args: expectStringArray(record.args, `${label}.args`), + env: expectStringRecord(record.env, `${label}.env`), + timeoutMs: expectNumber(record.timeoutMs, `${label}.timeoutMs`), + }; +} + +function decodeConfigurationLlmApiKeyStatus( + value: unknown, + label = "ConfigurationLlmApiKeyStatus" +): ConfigurationLlmApiKeyStatus { + const record = expectRecord(value, label); + return { + providerName: expectString(record.providerName, `${label}.providerName`), + configured: expectBoolean(record.configured, `${label}.configured`), + masked: expectString(record.masked, `${label}.masked`), + value: expectOptionalString(record.value, `${label}.value`), + }; +} + +function decodeConfigurationSecretValueStatus( + value: unknown, + label = "ConfigurationSecretValueStatus" +): ConfigurationSecretValueStatus { + const record = expectRecord(value, label); + return { + configured: expectBoolean(record.configured, `${label}.configured`), + masked: expectString(record.masked, `${label}.masked`), + keyPath: expectString(record.keyPath, `${label}.keyPath`), + value: expectOptionalString(record.value, `${label}.value`), + }; +} + +function decodeConfigurationEmbeddingsStatus( + value: unknown, + label = "ConfigurationEmbeddingsStatus" +): ConfigurationEmbeddingsStatus { + const record = expectRecord(value, label); + return { + enabled: expectNullableBoolean(record.enabled, `${label}.enabled`), + providerType: expectString(record.providerType, `${label}.providerType`), + endpoint: expectString(record.endpoint, `${label}.endpoint`), + model: expectString(record.model, `${label}.model`), + configured: expectBoolean(record.configured, `${label}.configured`), + masked: expectString(record.masked, `${label}.masked`), + }; +} + +function decodeConfigurationWebSearchStatus( + value: unknown, + label = "ConfigurationWebSearchStatus" +): ConfigurationWebSearchStatus { + const record = expectRecord(value, label); + return { + enabled: expectNullableBoolean(record.enabled, `${label}.enabled`), + effectiveEnabled: expectBoolean( + record.effectiveEnabled, + `${label}.effectiveEnabled` + ), + provider: expectString(record.provider, `${label}.provider`), + endpoint: expectString(record.endpoint, `${label}.endpoint`), + timeoutMs: expectNullableNumber(record.timeoutMs, `${label}.timeoutMs`), + searchDepth: expectString(record.searchDepth, `${label}.searchDepth`), + configured: expectBoolean(record.configured, `${label}.configured`), + masked: expectString(record.masked, `${label}.masked`), + available: expectBoolean(record.available, `${label}.available`), + }; +} + +function decodeConfigurationSkillsMpStatus( + value: unknown, + label = "ConfigurationSkillsMpStatus" +): ConfigurationSkillsMpStatus { + const record = expectRecord(value, label); + return { + configured: expectBoolean(record.configured, `${label}.configured`), + masked: expectString(record.masked, `${label}.masked`), + keyPath: expectString(record.keyPath, `${label}.keyPath`), + baseUrl: expectString(record.baseUrl, `${label}.baseUrl`), + }; +} + +function decodeConfigurationSecp256k1PrivateKeyStatus( + value: unknown, + label = "ConfigurationSecp256k1PrivateKeyStatus" +): ConfigurationSecp256k1PrivateKeyStatus { + const record = expectRecord(value, label); + return { + configured: expectBoolean(record.configured, `${label}.configured`), + masked: expectString(record.masked, `${label}.masked`), + keyPath: expectString(record.keyPath, `${label}.keyPath`), + backupsPrefix: expectString(record.backupsPrefix, `${label}.backupsPrefix`), + backupCount: expectNumber(record.backupCount, `${label}.backupCount`), + }; +} + +function decodeConfigurationSecp256k1PublicKeyStatus( + value: unknown, + label = "ConfigurationSecp256k1PublicKeyStatus" +): ConfigurationSecp256k1PublicKeyStatus { + const record = expectRecord(value, label); + return { + configured: expectBoolean(record.configured, `${label}.configured`), + hex: expectString(record.hex, `${label}.hex`), + }; +} + +function decodeConfigurationSecp256k1Status( + value: unknown, + label = "ConfigurationSecp256k1Status" +): ConfigurationSecp256k1Status { + const record = expectRecord(value, label); + return { + configured: expectBoolean(record.configured, `${label}.configured`), + privateKey: decodeConfigurationSecp256k1PrivateKeyStatus( + record.privateKey, + `${label}.privateKey` + ), + publicKey: decodeConfigurationSecp256k1PublicKeyStatus( + record.publicKey, + `${label}.publicKey` + ), + }; +} + +function decodeConfigurationSecp256k1GenerateResult( + value: unknown, + label = "ConfigurationSecp256k1GenerateResult" +): ConfigurationSecp256k1GenerateResult { + const record = expectRecord(value, label); + return { + backedUp: expectBoolean(record.backedUp, `${label}.backedUp`), + publicKeyHex: expectString(record.publicKeyHex, `${label}.publicKeyHex`), + status: decodeConfigurationSecp256k1Status( + record.status, + `${label}.status` + ), + }; +} + +function decodeConfigurationLlmProbeResult( + value: unknown, + label = "ConfigurationLlmProbeResult" +): ConfigurationLlmProbeResult { + const record = expectRecord(value, label); + return { + ok: expectBoolean(record.ok, `${label}.ok`), + providerName: expectString(record.providerName, `${label}.providerName`), + kind: expectString(record.kind, `${label}.kind`), + endpoint: expectString(record.endpoint, `${label}.endpoint`), + latencyMs: expectOptionalNumber(record.latencyMs, `${label}.latencyMs`), + error: expectOptionalString(record.error, `${label}.error`), + modelsCount: expectOptionalNumber( + record.modelsCount, + `${label}.modelsCount` + ), + sampleModels: + record.sampleModels === undefined + ? undefined + : expectStringArray(record.sampleModels, `${label}.sampleModels`), + models: + record.models === undefined + ? undefined + : expectStringArray(record.models, `${label}.models`), + }; +} + +function decodeConfigurationLlmProviderType( + value: unknown, + label = "ConfigurationLlmProviderType" +): ConfigurationLlmProviderType { + const record = expectRecord(value, label); + return { + id: expectString(record.id, `${label}.id`), + displayName: expectString(record.displayName, `${label}.displayName`), + category: expectString(record.category, `${label}.category`), + description: expectString(record.description, `${label}.description`), + recommended: expectBoolean(record.recommended, `${label}.recommended`), + configuredInstancesCount: expectNumber( + record.configuredInstancesCount, + `${label}.configuredInstancesCount` + ), + }; +} + +function decodeConfigurationLlmInstance( + value: unknown, + label = "ConfigurationLlmInstance" +): ConfigurationLlmInstance { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + providerType: expectString(record.providerType, `${label}.providerType`), + providerDisplayName: expectString( + record.providerDisplayName, + `${label}.providerDisplayName` + ), + model: expectString(record.model, `${label}.model`), + endpoint: expectString(record.endpoint, `${label}.endpoint`), + }; +} + +export const decodeConfigurationSourceStatusResponse: Decoder< + ConfigurationSourceStatus +> = (value) => decodeConfigurationSourceStatus(value); + +export const decodeConfigurationWorkflowFilesResponse: Decoder< + ConfigurationWorkflowFile[] +> = (value) => { + const record = expectRecord(value, "ConfigurationWorkflowFilesResponse"); + return expectArray( + record.workflows, + "ConfigurationWorkflowFilesResponse.workflows", + decodeConfigurationWorkflowFile + ); +}; + +export const decodeConfigurationWorkflowFileDetailResponse: Decoder< + ConfigurationWorkflowFileDetail +> = (value) => { + const record = expectRecord(value, "ConfigurationWorkflowFileDetailResponse"); + return decodeConfigurationWorkflowFileDetail( + record.workflow, + "ConfigurationWorkflowFileDetailResponse.workflow" + ); +}; + +export const decodeConfigurationWorkflowFileMutationResponse: Decoder< + ConfigurationWorkflowFile +> = (value) => { + const record = expectRecord( + value, + "ConfigurationWorkflowFileMutationResponse" + ); + return decodeConfigurationWorkflowFile( + record.workflow, + "ConfigurationWorkflowFileMutationResponse.workflow" + ); +}; + +export const decodeConfigurationRawDocumentResponse: Decoder< + ConfigurationRawDocument +> = (value) => decodeConfigurationRawDocument(value); + +export const decodeConfigurationCollectionRawDocumentResponse: Decoder< + ConfigurationCollectionRawDocument +> = (value) => decodeConfigurationCollectionRawDocument(value); + +export const decodeConfigurationValidationResultResponse: Decoder< + ConfigurationValidationResult +> = (value) => decodeConfigurationValidationResult(value); + +export const decodeConfigurationMcpServersResponse: Decoder< + ConfigurationMcpServer[] +> = (value) => { + const record = expectRecord(value, "ConfigurationMcpServersResponse"); + return expectArray( + record.servers, + "ConfigurationMcpServersResponse.servers", + decodeConfigurationMcpServer + ); +}; + +export const decodeConfigurationMcpServerMutationResponse: Decoder< + ConfigurationMcpServer +> = (value) => { + const record = expectRecord(value, "ConfigurationMcpServerMutationResponse"); + return decodeConfigurationMcpServer( + record.server, + "ConfigurationMcpServerMutationResponse.server" + ); +}; + +export const decodeConfigurationLlmApiKeyStatusResponse: Decoder< + ConfigurationLlmApiKeyStatus +> = (value) => decodeConfigurationLlmApiKeyStatus(value); + +export const decodeConfigurationSecretValueStatusResponse: Decoder< + ConfigurationSecretValueStatus +> = (value) => decodeConfigurationSecretValueStatus(value); + +export const decodeConfigurationEmbeddingsStatusResponse: Decoder< + ConfigurationEmbeddingsStatus +> = (value) => { + const record = expectRecord(value, "ConfigurationEmbeddingsStatusResponse"); + return decodeConfigurationEmbeddingsStatus( + record.embeddings, + "ConfigurationEmbeddingsStatusResponse.embeddings" + ); +}; + +export const decodeConfigurationWebSearchStatusResponse: Decoder< + ConfigurationWebSearchStatus +> = (value) => { + const record = expectRecord(value, "ConfigurationWebSearchStatusResponse"); + return decodeConfigurationWebSearchStatus( + record.webSearch, + "ConfigurationWebSearchStatusResponse.webSearch" + ); +}; + +export const decodeConfigurationSkillsMpStatusResponse: Decoder< + ConfigurationSkillsMpStatus +> = (value) => decodeConfigurationSkillsMpStatus(value); + +export const decodeConfigurationSecp256k1StatusResponse: Decoder< + ConfigurationSecp256k1Status +> = (value) => decodeConfigurationSecp256k1Status(value); + +export const decodeConfigurationSecp256k1GenerateResponse: Decoder< + ConfigurationSecp256k1GenerateResult +> = (value) => decodeConfigurationSecp256k1GenerateResult(value); + +export const decodeConfigurationLlmProbeResultResponse: Decoder< + ConfigurationLlmProbeResult +> = (value) => decodeConfigurationLlmProbeResult(value); + +export const decodeConfigurationLlmProviderTypesResponse: Decoder< + ConfigurationLlmProviderType[] +> = (value) => { + const record = expectRecord(value, "ConfigurationLlmProviderTypesResponse"); + return expectArray( + record.providers, + "ConfigurationLlmProviderTypesResponse.providers", + decodeConfigurationLlmProviderType + ); +}; + +export const decodeConfigurationLlmInstancesResponse: Decoder< + ConfigurationLlmInstance[] +> = (value) => { + const record = expectRecord(value, "ConfigurationLlmInstancesResponse"); + return expectArray( + record.instances, + "ConfigurationLlmInstancesResponse.instances", + decodeConfigurationLlmInstance + ); +}; + +export const decodeConfigurationLlmDefaultResponse: Decoder = ( + value +) => { + const record = expectRecord(value, "ConfigurationLlmDefaultResponse"); + return expectString( + record.providerName, + "ConfigurationLlmDefaultResponse.providerName" + ); +}; diff --git a/apps/aevatar-console-web/src/shared/api/consoleApi.test.ts b/apps/aevatar-console-web/src/shared/api/consoleApi.test.ts deleted file mode 100644 index 85ccb331..00000000 --- a/apps/aevatar-console-web/src/shared/api/consoleApi.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { consoleApi } from './consoleApi'; - -describe('consoleApi', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it('decodes agent summaries from the API boundary', async () => { - const fetchMock = jest.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { - id: 'agent-1', - type: 'WorkflowAgent', - description: 'Primary workflow agent', - }, - ], - } satisfies Partial); - - global.fetch = fetchMock as typeof global.fetch; - - await expect(consoleApi.listAgents()).resolves.toEqual([ - { - id: 'agent-1', - type: 'WorkflowAgent', - description: 'Primary workflow agent', - }, - ]); - }); - - it('rejects malformed JSON payloads instead of trusting casts', async () => { - const fetchMock = jest.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { - id: 'agent-1', - type: 'WorkflowAgent', - description: 123, - }, - ], - } satisfies Partial); - - global.fetch = fetchMock as typeof global.fetch; - - await expect(consoleApi.listAgents()).rejects.toThrow( - 'WorkflowAgentSummary[][0].description must be a string.', - ); - }); - - it('surfaces non-OK streamChat responses before SSE parsing starts', async () => { - const fetchMock = jest.fn().mockResolvedValue({ - ok: false, - status: 400, - text: async () => '{"message":"invalid workflow yaml"}', - } satisfies Partial); - - global.fetch = fetchMock as typeof global.fetch; - - await expect( - consoleApi.streamChat( - { - prompt: 'Run it', - workflowYamls: ['name: broken'], - }, - new AbortController().signal, - ), - ).rejects.toThrow('invalid workflow yaml'); - }); - - it('returns structured parse errors from the authoring parse endpoint', async () => { - const fetchMock = jest.fn().mockResolvedValue({ - ok: false, - status: 400, - text: async () => - JSON.stringify({ - valid: false, - error: 'invalid yaml', - errors: ['invalid yaml'], - definition: null, - edges: [], - }), - } satisfies Partial); - - global.fetch = fetchMock as typeof global.fetch; - - await expect( - consoleApi.parseWorkflow({ - yaml: 'broken', - }), - ).resolves.toEqual({ - valid: false, - error: 'invalid yaml', - errors: ['invalid yaml'], - definition: null, - edges: [], - }); - }); - - it('decodes the full runtime capability document', async () => { - const fetchMock = jest.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - schemaVersion: 'capabilities.v1', - generatedAtUtc: '2026-03-17T00:00:00Z', - primitives: [ - { - name: 'llm_call', - aliases: ['llm'], - category: 'ai', - description: 'LLM invocation', - closedWorldBlocked: false, - runtimeModule: 'Aevatar.Workflow.Core.Primitives.LlmCall', - parameters: [], - }, - ], - connectors: [ - { - name: 'memory', - type: 'mcp', - enabled: true, - timeoutMs: 15000, - retry: 2, - allowedInputKeys: ['query'], - allowedOperations: ['search'], - fixedArguments: [], - }, - ], - workflows: [ - { - name: 'incident_triage', - description: 'Triage workflow', - source: 'repo', - closedWorldMode: true, - requiresLlmProvider: true, - primitives: ['llm_call'], - requiredConnectors: ['memory'], - workflowCalls: [], - steps: [ - { - id: 'start', - type: 'llm_call', - next: '', - }, - ], - }, - ], - }), - } satisfies Partial); - - global.fetch = fetchMock as typeof global.fetch; - - await expect(consoleApi.getCapabilities()).resolves.toEqual({ - schemaVersion: 'capabilities.v1', - generatedAtUtc: '2026-03-17T00:00:00Z', - primitives: [ - { - name: 'llm_call', - aliases: ['llm'], - category: 'ai', - description: 'LLM invocation', - closedWorldBlocked: false, - runtimeModule: 'Aevatar.Workflow.Core.Primitives.LlmCall', - parameters: [], - }, - ], - connectors: [ - { - name: 'memory', - type: 'mcp', - enabled: true, - timeoutMs: 15000, - retry: 2, - allowedInputKeys: ['query'], - allowedOperations: ['search'], - fixedArguments: [], - }, - ], - workflows: [ - { - name: 'incident_triage', - description: 'Triage workflow', - source: 'repo', - closedWorldMode: true, - requiresLlmProvider: true, - primitives: ['llm_call'], - requiredConnectors: ['memory'], - workflowCalls: [], - steps: [ - { - id: 'start', - type: 'llm_call', - next: '', - }, - ], - }, - ], - }); - }); -}); diff --git a/apps/aevatar-console-web/src/shared/api/consoleApi.ts b/apps/aevatar-console-web/src/shared/api/consoleApi.ts deleted file mode 100644 index a1217dd0..00000000 --- a/apps/aevatar-console-web/src/shared/api/consoleApi.ts +++ /dev/null @@ -1,374 +0,0 @@ -import type { - ChatRunRequest, - WorkflowResumeRequest, - WorkflowResumeResponse, - WorkflowSignalRequest, - WorkflowSignalResponse, -} from '@aevatar-react-sdk/types'; -import type { - PlaygroundWorkflowParseResult, - PlaygroundWorkflowSaveResult, - WorkflowActorGraphEdge, - WorkflowActorGraphSubgraph, - WorkflowActorGraphEnrichedSnapshot, - WorkflowActorSnapshot, - WorkflowActorTimelineItem, - WorkflowAgentSummary, - WorkflowLlmStatus, - WorkflowCapabilities, - WorkflowCatalogItem, - WorkflowCatalogItemDetail, - WorkflowPrimitiveDescriptor, -} from './models'; -import { - type Decoder, - decodePlaygroundWorkflowParseResponse, - decodePlaygroundWorkflowSaveResponse, - decodeWorkflowActorGraphEdgesResponse, - decodeWorkflowActorGraphEnrichedResponse, - decodeWorkflowActorGraphSubgraphResponse, - decodeWorkflowActorSnapshotResponse, - decodeWorkflowActorTimelineResponse, - decodeWorkflowAgentSummaries, - decodeWorkflowCapabilitiesResponse, - decodeWorkflowCatalogItemDetailResponse, - decodeWorkflowCatalogItems, - decodeWorkflowLlmStatusResponse, - decodeWorkflowNames, - decodeWorkflowPrimitiveDescriptorsResponse, - decodeWorkflowResumeResponseBody, - decodeWorkflowSignalResponseBody, -} from './decoders'; -import { authFetch } from '@/shared/auth/fetch'; - -const JSON_HEADERS = { - 'Content-Type': 'application/json', - Accept: 'application/json', -}; - -function trimOptional(value?: string): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - -function compactObject>(value: T): T { - return Object.fromEntries( - Object.entries(value).filter(([, entry]) => entry !== undefined), - ) as T; -} - -async function readError(response: Response): Promise { - const text = await response.text(); - if (!text) { - return `HTTP ${response.status}`; - } - - try { - const payload = JSON.parse(text) as { - message?: string; - error?: string; - code?: string; - }; - return payload.message || payload.error || payload.code || text; - } catch { - return text; - } -} - -async function requestJson( - input: string, - decoder: Decoder, - init?: RequestInit, -): Promise { - const response = await authFetch(input, init); - if (!response.ok) { - throw new Error(await readError(response)); - } - - return decoder(await response.json()); -} - -function parseJsonText(text: string): unknown { - if (!text) { - return {}; - } - - try { - return JSON.parse(text); - } catch { - return {}; - } -} - -function readErrorFromPayload( - payload: unknown, - fallback: string, - status: number, -): string { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - return fallback || `HTTP ${status}`; - } - - const record = payload as Record; - const message = record.message || record.error || record.code; - return typeof message === 'string' && message.trim().length > 0 - ? message - : fallback || `HTTP ${status}`; -} - -export const consoleApi = { - listAgents(): Promise { - return requestJson('/api/agents', decodeWorkflowAgentSummaries); - }, - - listWorkflows(): Promise { - return requestJson('/api/workflows', decodeWorkflowNames); - }, - - listWorkflowCatalog(): Promise { - return requestJson('/api/workflow-catalog', decodeWorkflowCatalogItems); - }, - - getCapabilities(): Promise { - return requestJson('/api/capabilities', decodeWorkflowCapabilitiesResponse); - }, - - async parseWorkflow(input: { yaml: string }): Promise { - const response = await authFetch('/api/workflow-authoring/parse', { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify({ - yaml: input.yaml, - }), - }); - const text = await response.text(); - const payload = parseJsonText(text); - if (!response.ok && response.status !== 400) { - throw new Error(readErrorFromPayload(payload, text, response.status)); - } - - return decodePlaygroundWorkflowParseResponse(payload); - }, - - saveWorkflow(input: { - yaml: string; - filename?: string; - overwrite?: boolean; - }): Promise { - return requestJson( - '/api/workflow-authoring/workflows', - decodePlaygroundWorkflowSaveResponse, - { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify( - compactObject({ - yaml: input.yaml, - filename: trimOptional(input.filename), - overwrite: input.overwrite ?? false, - }), - ), - }, - ); - }, - - listPrimitives(): Promise { - return requestJson( - '/api/primitives', - decodeWorkflowPrimitiveDescriptorsResponse, - ); - }, - - getLlmStatus(): Promise { - return requestJson('/api/llm/status', decodeWorkflowLlmStatusResponse); - }, - - getWorkflowDetail(workflowName: string): Promise { - return requestJson( - `/api/workflows/${encodeURIComponent(workflowName)}`, - decodeWorkflowCatalogItemDetailResponse, - ); - }, - - getActorSnapshot(actorId: string): Promise { - return requestJson( - `/api/actors/${encodeURIComponent(actorId)}`, - decodeWorkflowActorSnapshotResponse, - ); - }, - - getActorTimeline( - actorId: string, - options?: { take?: number }, - ): Promise { - const params = new URLSearchParams(); - if (options?.take) { - params.set('take', String(options.take)); - } - - const suffix = params.size > 0 ? `?${params.toString()}` : ''; - return requestJson( - `/api/actors/${encodeURIComponent(actorId)}/timeline${suffix}`, - decodeWorkflowActorTimelineResponse, - ); - }, - - getActorGraphEnriched( - actorId: string, - options?: { - depth?: number; - take?: number; - direction?: 'Both' | 'Outbound' | 'Inbound'; - edgeTypes?: string[]; - }, - ): Promise { - const params = new URLSearchParams(); - if (options?.depth) { - params.set('depth', String(options.depth)); - } - if (options?.take) { - params.set('take', String(options.take)); - } - if (options?.direction) { - params.set('direction', options.direction); - } - for (const edgeType of options?.edgeTypes ?? []) { - const normalized = trimOptional(edgeType); - if (normalized) { - params.append('edgeTypes', normalized); - } - } - - const suffix = params.size > 0 ? `?${params.toString()}` : ''; - return requestJson( - `/api/actors/${encodeURIComponent(actorId)}/graph-enriched${suffix}`, - decodeWorkflowActorGraphEnrichedResponse, - ); - }, - - getActorGraphEdges( - actorId: string, - options?: { - take?: number; - direction?: 'Both' | 'Outbound' | 'Inbound'; - edgeTypes?: string[]; - }, - ): Promise { - const params = new URLSearchParams(); - if (options?.take) { - params.set('take', String(options.take)); - } - if (options?.direction) { - params.set('direction', options.direction); - } - for (const edgeType of options?.edgeTypes ?? []) { - const normalized = trimOptional(edgeType); - if (normalized) { - params.append('edgeTypes', normalized); - } - } - - const suffix = params.size > 0 ? `?${params.toString()}` : ''; - return requestJson( - `/api/actors/${encodeURIComponent(actorId)}/graph-edges${suffix}`, - decodeWorkflowActorGraphEdgesResponse, - ); - }, - - getActorGraphSubgraph( - actorId: string, - options?: { - depth?: number; - take?: number; - direction?: 'Both' | 'Outbound' | 'Inbound'; - edgeTypes?: string[]; - }, - ): Promise { - const params = new URLSearchParams(); - if (options?.depth) { - params.set('depth', String(options.depth)); - } - if (options?.take) { - params.set('take', String(options.take)); - } - if (options?.direction) { - params.set('direction', options.direction); - } - for (const edgeType of options?.edgeTypes ?? []) { - const normalized = trimOptional(edgeType); - if (normalized) { - params.append('edgeTypes', normalized); - } - } - - const suffix = params.size > 0 ? `?${params.toString()}` : ''; - return requestJson( - `/api/actors/${encodeURIComponent(actorId)}/graph-subgraph${suffix}`, - decodeWorkflowActorGraphSubgraphResponse, - ); - }, - - async streamChat(request: ChatRunRequest, signal: AbortSignal): Promise { - const response = await authFetch('/api/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - }, - body: JSON.stringify( - compactObject({ - prompt: request.prompt.trim(), - workflow: trimOptional(request.workflow), - agentId: trimOptional(request.agentId), - workflowYamls: - request.workflowYamls && request.workflowYamls.length > 0 - ? request.workflowYamls - : undefined, - metadata: request.metadata, - }), - ), - signal, - }); - - if (!response.ok) { - throw new Error(await readError(response)); - } - - return response; - }, - - resume(request: WorkflowResumeRequest): Promise { - return requestJson('/api/workflows/resume', decodeWorkflowResumeResponseBody, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify( - compactObject({ - actorId: request.actorId, - runId: request.runId, - stepId: request.stepId, - commandId: trimOptional(request.commandId), - approved: request.approved, - userInput: trimOptional(request.userInput), - metadata: request.metadata, - }), - ), - }); - }, - - signal(request: WorkflowSignalRequest): Promise { - return requestJson('/api/workflows/signal', decodeWorkflowSignalResponseBody, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify( - compactObject({ - actorId: request.actorId, - runId: request.runId, - signalName: request.signalName, - stepId: trimOptional(request.stepId), - commandId: trimOptional(request.commandId), - payload: trimOptional(request.payload), - }), - ), - }); - }, -}; diff --git a/apps/aevatar-console-web/src/shared/api/decodeUtils.ts b/apps/aevatar-console-web/src/shared/api/decodeUtils.ts new file mode 100644 index 00000000..6b1fe2e9 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/decodeUtils.ts @@ -0,0 +1,116 @@ +export type Decoder = (value: unknown, label?: string) => T; + +type JsonRecord = Record; + +export function expectRecord(value: unknown, label: string): JsonRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + + return value as JsonRecord; +} + +export function expectArray( + value: unknown, + label: string, + decoder: Decoder +): T[] { + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array.`); + } + + return value.map((entry, index) => decoder(entry, `${label}[${index}]`)); +} + +export function expectString(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new Error(`${label} must be a string.`); + } + + return value; +} + +export function expectBoolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new Error(`${label} must be a boolean.`); + } + + return value; +} + +export function expectNumber(value: unknown, label: string): number { + if (typeof value !== "number" || Number.isNaN(value)) { + throw new Error(`${label} must be a number.`); + } + + return value; +} + +export function expectNullableNumber( + value: unknown, + label: string +): number | null { + return value === null ? null : expectNumber(value, label); +} + +export function expectNullableBoolean( + value: unknown, + label: string +): boolean | null { + return value === null ? null : expectBoolean(value, label); +} + +export function expectNullableString( + value: unknown, + label: string +): string | null { + return value === null ? null : expectString(value, label); +} + +export function expectOptionalString( + value: unknown, + label: string +): string | undefined { + return value === undefined || value === null + ? undefined + : expectString(value, label); +} + +export function expectOptionalBoolean( + value: unknown, + label: string +): boolean | undefined { + return value === undefined || value === null + ? undefined + : expectBoolean(value, label); +} + +export function expectOptionalNumber( + value: unknown, + label: string +): number | undefined { + return value === undefined || value === null + ? undefined + : expectNumber(value, label); +} + +export function expectStringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array.`); + } + + return value.map((entry, index) => expectString(entry, `${label}[${index}]`)); +} + +export function expectStringRecord( + value: unknown, + label: string +): Record { + const record = expectRecord(value, label); + return Object.fromEntries( + Object.entries(record).map(([key, entry]) => [ + key, + expectString(entry, `${label}.${key}`), + ]) + ); +} diff --git a/apps/aevatar-console-web/src/shared/api/decoders.ts b/apps/aevatar-console-web/src/shared/api/decoders.ts index b9614a3a..6f7b958d 100644 --- a/apps/aevatar-console-web/src/shared/api/decoders.ts +++ b/apps/aevatar-console-web/src/shared/api/decoders.ts @@ -1,1314 +1,3 @@ -import type { - WorkflowResumeResponse, - WorkflowSignalResponse, -} from '@aevatar-react-sdk/types'; -import type { - ConfigurationCollectionRawDocument, - ConfigurationDoctorReport, - ConfigurationEmbeddingsStatus, - ConfigurationLlmApiKeyStatus, - ConfigurationMcpServer, - ConfigurationLlmProbeResult, - ConfigurationLlmInstance, - ConfigurationLlmProviderType, - ConfigurationPathInfo, - ConfigurationPathStatus, - ConfigurationRawDocument, - ConfigurationSecretValueStatus, - ConfigurationSecp256k1GenerateResult, - ConfigurationSecp256k1PrivateKeyStatus, - ConfigurationSecp256k1PublicKeyStatus, - ConfigurationSecp256k1Status, - ConfigurationSkillsMpStatus, - ConfigurationSourceStatus, - ConfigurationValidationResult, - ConfigurationWebSearchStatus, - ConfigurationWorkflowFile, - ConfigurationWorkflowFileDetail, - PlaygroundWorkflowParseResult, - PlaygroundWorkflowSaveResult, - WorkflowActorGraphEdge, - WorkflowActorGraphEnrichedSnapshot, - WorkflowActorGraphNode, - WorkflowActorGraphSubgraph, - WorkflowActorSnapshot, - WorkflowActorTimelineItem, - WorkflowAgentSummary, - WorkflowAuthoringDefinition, - WorkflowAuthoringEdge, - WorkflowAuthoringErrorPolicy, - WorkflowAuthoringRetryPolicy, - WorkflowAuthoringRole, - WorkflowAuthoringStep, - WorkflowCapabilities, - WorkflowCapabilityParameter, - WorkflowCapabilityWorkflow, - WorkflowCapabilityWorkflowStep, - WorkflowConnectorCapability, - WorkflowCatalogChildStep, - WorkflowCatalogDefinition, - WorkflowCatalogEdge, - WorkflowCatalogItem, - WorkflowCatalogItemDetail, - WorkflowCatalogRole, - WorkflowCatalogStep, - WorkflowLlmStatus, - WorkflowPrimitiveCapability, - WorkflowPrimitiveDescriptor, - WorkflowPrimitiveParameterDescriptor, -} from './models'; - -export type Decoder = (value: unknown, label?: string) => T; - -type JsonRecord = Record; - -function expectRecord(value: unknown, label: string): JsonRecord { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${label} must be an object.`); - } - - return value as JsonRecord; -} - -function expectArray( - value: unknown, - label: string, - decoder: Decoder, -): T[] { - if (!Array.isArray(value)) { - throw new Error(`${label} must be an array.`); - } - - return value.map((entry, index) => decoder(entry, `${label}[${index}]`)); -} - -function expectString(value: unknown, label: string): string { - if (typeof value !== 'string') { - throw new Error(`${label} must be a string.`); - } - - return value; -} - -function expectBoolean(value: unknown, label: string): boolean { - if (typeof value !== 'boolean') { - throw new Error(`${label} must be a boolean.`); - } - - return value; -} - -function expectNumber(value: unknown, label: string): number { - if (typeof value !== 'number' || Number.isNaN(value)) { - throw new Error(`${label} must be a number.`); - } - - return value; -} - -function expectNullableNumber(value: unknown, label: string): number | null { - return value === null ? null : expectNumber(value, label); -} - -function expectNullableBoolean(value: unknown, label: string): boolean | null { - return value === null ? null : expectBoolean(value, label); -} - -function expectNullableString(value: unknown, label: string): string | null { - return value === null ? null : expectString(value, label); -} - -function expectOptionalString(value: unknown, label: string): string | undefined { - return value === undefined || value === null - ? undefined - : expectString(value, label); -} - -function expectOptionalBoolean(value: unknown, label: string): boolean | undefined { - return value === undefined || value === null - ? undefined - : expectBoolean(value, label); -} - -function expectOptionalNumber(value: unknown, label: string): number | undefined { - return value === undefined || value === null - ? undefined - : expectNumber(value, label); -} - -function expectStringArray(value: unknown, label: string): string[] { - if (!Array.isArray(value)) { - throw new Error(`${label} must be an array.`); - } - - return value.map((entry, index) => expectString(entry, `${label}[${index}]`)); -} - -function expectStringRecord( - value: unknown, - label: string, -): Record { - const record = expectRecord(value, label); - return Object.fromEntries( - Object.entries(record).map(([key, entry]) => [ - key, - expectString(entry, `${label}.${key}`), - ]), - ); -} - -function decodeWorkflowCatalogItem(value: unknown, label = 'WorkflowCatalogItem'): WorkflowCatalogItem { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - description: expectString(record.description, `${label}.description`), - category: expectString(record.category, `${label}.category`), - group: expectString(record.group, `${label}.group`), - groupLabel: expectString(record.groupLabel, `${label}.groupLabel`), - sortOrder: expectNumber(record.sortOrder, `${label}.sortOrder`), - source: expectString(record.source, `${label}.source`), - sourceLabel: expectString(record.sourceLabel, `${label}.sourceLabel`), - showInLibrary: expectBoolean(record.showInLibrary, `${label}.showInLibrary`), - isPrimitiveExample: expectBoolean( - record.isPrimitiveExample, - `${label}.isPrimitiveExample`, - ), - requiresLlmProvider: expectBoolean( - record.requiresLlmProvider, - `${label}.requiresLlmProvider`, - ), - primitives: expectStringArray(record.primitives, `${label}.primitives`), - }; -} - -function decodeConfigurationPathInfo( - value: unknown, - label = 'ConfigurationPathInfo', -): ConfigurationPathInfo { - const record = expectRecord(value, label); - const homeEnvValue = expectOptionalString(record.homeEnvValue, `${label}.homeEnvValue`); - const secretsPathEnvValue = expectOptionalString( - record.secretsPathEnvValue, - `${label}.secretsPathEnvValue`, - ); - return { - root: expectString(record.root, `${label}.root`), - secretsJson: expectString(record.secretsJson, `${label}.secretsJson`), - configJson: expectString(record.configJson, `${label}.configJson`), - connectorsJson: expectString(record.connectorsJson, `${label}.connectorsJson`), - mcpJson: expectString(record.mcpJson, `${label}.mcpJson`), - workflowsHome: expectString(record.workflowsHome, `${label}.workflowsHome`), - workflowsRepo: expectString(record.workflowsRepo, `${label}.workflowsRepo`), - homeEnvValue: homeEnvValue ?? null, - secretsPathEnvValue: secretsPathEnvValue ?? null, - }; -} - -function decodeConfigurationPathStatus( - value: unknown, - label = 'ConfigurationPathStatus', -): ConfigurationPathStatus { - const record = expectRecord(value, label); - const sizeBytes = expectOptionalNumber(record.sizeBytes, `${label}.sizeBytes`); - const error = expectOptionalString(record.error, `${label}.error`); - return { - path: expectString(record.path, `${label}.path`), - exists: expectBoolean(record.exists, `${label}.exists`), - readable: expectBoolean(record.readable, `${label}.readable`), - writable: expectBoolean(record.writable, `${label}.writable`), - sizeBytes: sizeBytes ?? null, - error: error ?? null, - }; -} - -function decodeConfigurationDoctorReport( - value: unknown, - label = 'ConfigurationDoctorReport', -): ConfigurationDoctorReport { - const record = expectRecord(value, label); - return { - paths: decodeConfigurationPathInfo(record.paths, `${label}.paths`), - secrets: decodeConfigurationPathStatus(record.secrets, `${label}.secrets`), - config: decodeConfigurationPathStatus(record.config, `${label}.config`), - connectors: decodeConfigurationPathStatus( - record.connectors, - `${label}.connectors`, - ), - mcp: decodeConfigurationPathStatus(record.mcp, `${label}.mcp`), - workflowsHome: decodeConfigurationPathStatus( - record.workflowsHome, - `${label}.workflowsHome`, - ), - workflowsRepo: decodeConfigurationPathStatus( - record.workflowsRepo, - `${label}.workflowsRepo`, - ), - }; -} - -function decodeConfigurationSourceStatus( - value: unknown, - label = 'ConfigurationSourceStatus', -): ConfigurationSourceStatus { - const record = expectRecord(value, label); - return { - mode: expectString(record.mode, `${label}.mode`), - mongoConfigured: expectBoolean(record.mongoConfigured, `${label}.mongoConfigured`), - fileConfigured: expectBoolean(record.fileConfigured, `${label}.fileConfigured`), - localRuntimeAccess: expectBoolean( - record.localRuntimeAccess, - `${label}.localRuntimeAccess`, - ), - paths: decodeConfigurationPathInfo(record.paths, `${label}.paths`), - doctor: decodeConfigurationDoctorReport(record.doctor, `${label}.doctor`), - }; -} - -function decodeConfigurationWorkflowFile( - value: unknown, - label = 'ConfigurationWorkflowFile', -): ConfigurationWorkflowFile { - const record = expectRecord(value, label); - return { - filename: expectString(record.filename, `${label}.filename`), - source: expectString(record.source, `${label}.source`), - path: expectString(record.path, `${label}.path`), - sizeBytes: expectNumber(record.sizeBytes, `${label}.sizeBytes`), - lastModified: expectString(record.lastModified, `${label}.lastModified`), - }; -} - -function decodeConfigurationWorkflowFileDetail( - value: unknown, - label = 'ConfigurationWorkflowFileDetail', -): ConfigurationWorkflowFileDetail { - const record = expectRecord(value, label); - return { - ...decodeConfigurationWorkflowFile(value, label), - content: expectString(record.content, `${label}.content`), - }; -} - -function decodeConfigurationRawDocument( - value: unknown, - label = 'ConfigurationRawDocument', -): ConfigurationRawDocument { - const record = expectRecord(value, label); - return { - json: expectString(record.json, `${label}.json`), - keyCount: expectNumber(record.keyCount, `${label}.keyCount`), - exists: expectOptionalBoolean(record.exists, `${label}.exists`), - path: expectOptionalString(record.path, `${label}.path`), - }; -} - -function decodeConfigurationCollectionRawDocument( - value: unknown, - label = 'ConfigurationCollectionRawDocument', -): ConfigurationCollectionRawDocument { - const record = expectRecord(value, label); - return { - json: expectString(record.json, `${label}.json`), - count: expectNumber(record.count, `${label}.count`), - exists: expectOptionalBoolean(record.exists, `${label}.exists`), - path: expectOptionalString(record.path, `${label}.path`), - }; -} - -function decodeConfigurationValidationResult( - value: unknown, - label = 'ConfigurationValidationResult', -): ConfigurationValidationResult { - const record = expectRecord(value, label); - return { - valid: expectBoolean(record.valid, `${label}.valid`), - message: expectString(record.message, `${label}.message`), - count: expectNumber(record.count, `${label}.count`), - }; -} - -function decodeConfigurationMcpServer( - value: unknown, - label = 'ConfigurationMcpServer', -): ConfigurationMcpServer { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - command: expectString(record.command, `${label}.command`), - args: expectStringArray(record.args, `${label}.args`), - env: expectStringRecord(record.env, `${label}.env`), - timeoutMs: expectNumber(record.timeoutMs, `${label}.timeoutMs`), - }; -} - -function decodeConfigurationLlmApiKeyStatus( - value: unknown, - label = 'ConfigurationLlmApiKeyStatus', -): ConfigurationLlmApiKeyStatus { - const record = expectRecord(value, label); - return { - providerName: expectString(record.providerName, `${label}.providerName`), - configured: expectBoolean(record.configured, `${label}.configured`), - masked: expectString(record.masked, `${label}.masked`), - value: expectOptionalString(record.value, `${label}.value`), - }; -} - -function decodeConfigurationSecretValueStatus( - value: unknown, - label = 'ConfigurationSecretValueStatus', -): ConfigurationSecretValueStatus { - const record = expectRecord(value, label); - return { - configured: expectBoolean(record.configured, `${label}.configured`), - masked: expectString(record.masked, `${label}.masked`), - keyPath: expectString(record.keyPath, `${label}.keyPath`), - value: expectOptionalString(record.value, `${label}.value`), - }; -} - -function decodeConfigurationEmbeddingsStatus( - value: unknown, - label = 'ConfigurationEmbeddingsStatus', -): ConfigurationEmbeddingsStatus { - const record = expectRecord(value, label); - return { - enabled: expectNullableBoolean(record.enabled, `${label}.enabled`), - providerType: expectString(record.providerType, `${label}.providerType`), - endpoint: expectString(record.endpoint, `${label}.endpoint`), - model: expectString(record.model, `${label}.model`), - configured: expectBoolean(record.configured, `${label}.configured`), - masked: expectString(record.masked, `${label}.masked`), - }; -} - -function decodeConfigurationWebSearchStatus( - value: unknown, - label = 'ConfigurationWebSearchStatus', -): ConfigurationWebSearchStatus { - const record = expectRecord(value, label); - return { - enabled: expectNullableBoolean(record.enabled, `${label}.enabled`), - effectiveEnabled: expectBoolean(record.effectiveEnabled, `${label}.effectiveEnabled`), - provider: expectString(record.provider, `${label}.provider`), - endpoint: expectString(record.endpoint, `${label}.endpoint`), - timeoutMs: expectNullableNumber(record.timeoutMs, `${label}.timeoutMs`), - searchDepth: expectString(record.searchDepth, `${label}.searchDepth`), - configured: expectBoolean(record.configured, `${label}.configured`), - masked: expectString(record.masked, `${label}.masked`), - available: expectBoolean(record.available, `${label}.available`), - }; -} - -function decodeConfigurationSkillsMpStatus( - value: unknown, - label = 'ConfigurationSkillsMpStatus', -): ConfigurationSkillsMpStatus { - const record = expectRecord(value, label); - return { - configured: expectBoolean(record.configured, `${label}.configured`), - masked: expectString(record.masked, `${label}.masked`), - keyPath: expectString(record.keyPath, `${label}.keyPath`), - baseUrl: expectString(record.baseUrl, `${label}.baseUrl`), - }; -} - -function decodeConfigurationSecp256k1PrivateKeyStatus( - value: unknown, - label = 'ConfigurationSecp256k1PrivateKeyStatus', -): ConfigurationSecp256k1PrivateKeyStatus { - const record = expectRecord(value, label); - return { - configured: expectBoolean(record.configured, `${label}.configured`), - masked: expectString(record.masked, `${label}.masked`), - keyPath: expectString(record.keyPath, `${label}.keyPath`), - backupsPrefix: expectString(record.backupsPrefix, `${label}.backupsPrefix`), - backupCount: expectNumber(record.backupCount, `${label}.backupCount`), - }; -} - -function decodeConfigurationSecp256k1PublicKeyStatus( - value: unknown, - label = 'ConfigurationSecp256k1PublicKeyStatus', -): ConfigurationSecp256k1PublicKeyStatus { - const record = expectRecord(value, label); - return { - configured: expectBoolean(record.configured, `${label}.configured`), - hex: expectString(record.hex, `${label}.hex`), - }; -} - -function decodeConfigurationSecp256k1Status( - value: unknown, - label = 'ConfigurationSecp256k1Status', -): ConfigurationSecp256k1Status { - const record = expectRecord(value, label); - return { - configured: expectBoolean(record.configured, `${label}.configured`), - privateKey: decodeConfigurationSecp256k1PrivateKeyStatus( - record.privateKey, - `${label}.privateKey`, - ), - publicKey: decodeConfigurationSecp256k1PublicKeyStatus( - record.publicKey, - `${label}.publicKey`, - ), - }; -} - -function decodeConfigurationSecp256k1GenerateResult( - value: unknown, - label = 'ConfigurationSecp256k1GenerateResult', -): ConfigurationSecp256k1GenerateResult { - const record = expectRecord(value, label); - return { - backedUp: expectBoolean(record.backedUp, `${label}.backedUp`), - publicKeyHex: expectString(record.publicKeyHex, `${label}.publicKeyHex`), - status: decodeConfigurationSecp256k1Status(record.status, `${label}.status`), - }; -} - -function decodeConfigurationLlmProbeResult( - value: unknown, - label = 'ConfigurationLlmProbeResult', -): ConfigurationLlmProbeResult { - const record = expectRecord(value, label); - return { - ok: expectBoolean(record.ok, `${label}.ok`), - providerName: expectString(record.providerName, `${label}.providerName`), - kind: expectString(record.kind, `${label}.kind`), - endpoint: expectString(record.endpoint, `${label}.endpoint`), - latencyMs: expectOptionalNumber(record.latencyMs, `${label}.latencyMs`), - error: expectOptionalString(record.error, `${label}.error`), - modelsCount: expectOptionalNumber(record.modelsCount, `${label}.modelsCount`), - sampleModels: - record.sampleModels === undefined - ? undefined - : expectStringArray(record.sampleModels, `${label}.sampleModels`), - models: - record.models === undefined - ? undefined - : expectStringArray(record.models, `${label}.models`), - }; -} - -function decodeConfigurationLlmProviderType( - value: unknown, - label = 'ConfigurationLlmProviderType', -): ConfigurationLlmProviderType { - const record = expectRecord(value, label); - return { - id: expectString(record.id, `${label}.id`), - displayName: expectString(record.displayName, `${label}.displayName`), - category: expectString(record.category, `${label}.category`), - description: expectString(record.description, `${label}.description`), - recommended: expectBoolean(record.recommended, `${label}.recommended`), - configuredInstancesCount: expectNumber( - record.configuredInstancesCount, - `${label}.configuredInstancesCount`, - ), - }; -} - -function decodeConfigurationLlmInstance( - value: unknown, - label = 'ConfigurationLlmInstance', -): ConfigurationLlmInstance { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - providerType: expectString(record.providerType, `${label}.providerType`), - providerDisplayName: expectString( - record.providerDisplayName, - `${label}.providerDisplayName`, - ), - model: expectString(record.model, `${label}.model`), - endpoint: expectString(record.endpoint, `${label}.endpoint`), - }; -} - -function decodeWorkflowCatalogRole(value: unknown, label = 'WorkflowCatalogRole'): WorkflowCatalogRole { - const record = expectRecord(value, label); - return { - id: expectString(record.id, `${label}.id`), - name: expectString(record.name, `${label}.name`), - systemPrompt: expectString(record.systemPrompt, `${label}.systemPrompt`), - provider: expectString(record.provider, `${label}.provider`), - model: expectString(record.model, `${label}.model`), - temperature: expectNullableNumber(record.temperature, `${label}.temperature`), - maxTokens: expectNullableNumber(record.maxTokens, `${label}.maxTokens`), - maxToolRounds: expectNullableNumber( - record.maxToolRounds, - `${label}.maxToolRounds`, - ), - maxHistoryMessages: expectNullableNumber( - record.maxHistoryMessages, - `${label}.maxHistoryMessages`, - ), - streamBufferCapacity: expectNullableNumber( - record.streamBufferCapacity, - `${label}.streamBufferCapacity`, - ), - eventModules: expectStringArray(record.eventModules, `${label}.eventModules`), - eventRoutes: expectString(record.eventRoutes, `${label}.eventRoutes`), - connectors: expectStringArray(record.connectors, `${label}.connectors`), - }; -} - -function decodeWorkflowCatalogChildStep( - value: unknown, - label = 'WorkflowCatalogChildStep', -): WorkflowCatalogChildStep { - const record = expectRecord(value, label); - return { - id: expectString(record.id, `${label}.id`), - type: expectString(record.type, `${label}.type`), - targetRole: expectString(record.targetRole, `${label}.targetRole`), - }; -} - -function decodeWorkflowCatalogStep(value: unknown, label = 'WorkflowCatalogStep'): WorkflowCatalogStep { - const record = expectRecord(value, label); - return { - id: expectString(record.id, `${label}.id`), - type: expectString(record.type, `${label}.type`), - targetRole: expectString(record.targetRole, `${label}.targetRole`), - parameters: expectStringRecord(record.parameters, `${label}.parameters`), - next: expectString(record.next, `${label}.next`), - branches: expectStringRecord(record.branches, `${label}.branches`), - children: expectArray( - record.children, - `${label}.children`, - decodeWorkflowCatalogChildStep, - ), - }; -} - -function decodeWorkflowCatalogEdge(value: unknown, label = 'WorkflowCatalogEdge'): WorkflowCatalogEdge { - const record = expectRecord(value, label); - return { - from: expectString(record.from, `${label}.from`), - to: expectString(record.to, `${label}.to`), - label: expectString(record.label, `${label}.label`), - }; -} - -function decodeWorkflowCatalogDefinition( - value: unknown, - label = 'WorkflowCatalogDefinition', -): WorkflowCatalogDefinition { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - description: expectString(record.description, `${label}.description`), - closedWorldMode: expectBoolean( - record.closedWorldMode, - `${label}.closedWorldMode`, - ), - roles: expectArray(record.roles, `${label}.roles`, decodeWorkflowCatalogRole), - steps: expectArray(record.steps, `${label}.steps`, decodeWorkflowCatalogStep), - }; -} - -function decodeWorkflowCatalogItemDetail( - value: unknown, - label = 'WorkflowCatalogItemDetail', -): WorkflowCatalogItemDetail { - const record = expectRecord(value, label); - return { - catalog: decodeWorkflowCatalogItem(record.catalog, `${label}.catalog`), - yaml: expectString(record.yaml, `${label}.yaml`), - definition: decodeWorkflowCatalogDefinition( - record.definition, - `${label}.definition`, - ), - edges: expectArray(record.edges, `${label}.edges`, decodeWorkflowCatalogEdge), - }; -} - -function decodeWorkflowAgentSummary( - value: unknown, - label = 'WorkflowAgentSummary', -): WorkflowAgentSummary { - const record = expectRecord(value, label); - return { - id: expectString(record.id, `${label}.id`), - type: expectString(record.type, `${label}.type`), - description: expectString(record.description, `${label}.description`), - }; -} - -function decodeWorkflowCapabilityParameter( - value: unknown, - label = 'WorkflowCapabilityParameter', -): WorkflowCapabilityParameter { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - type: expectString(record.type, `${label}.type`), - required: expectBoolean(record.required, `${label}.required`), - description: expectString(record.description, `${label}.description`), - default: expectString(record.default, `${label}.default`), - enum: expectStringArray(record.enum, `${label}.enum`), - }; -} - -function decodeWorkflowPrimitiveCapability( - value: unknown, - label = 'WorkflowPrimitiveCapability', -): WorkflowPrimitiveCapability { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - aliases: expectStringArray(record.aliases, `${label}.aliases`), - category: expectString(record.category, `${label}.category`), - description: expectString(record.description, `${label}.description`), - closedWorldBlocked: expectBoolean( - record.closedWorldBlocked, - `${label}.closedWorldBlocked`, - ), - runtimeModule: expectString(record.runtimeModule, `${label}.runtimeModule`), - parameters: expectArray( - record.parameters, - `${label}.parameters`, - decodeWorkflowCapabilityParameter, - ), - }; -} - -function decodeWorkflowConnectorCapability( - value: unknown, - label = 'WorkflowConnectorCapability', -): WorkflowConnectorCapability { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - type: expectString(record.type, `${label}.type`), - enabled: expectBoolean(record.enabled, `${label}.enabled`), - timeoutMs: expectNumber(record.timeoutMs, `${label}.timeoutMs`), - retry: expectNumber(record.retry, `${label}.retry`), - allowedInputKeys: expectStringArray( - record.allowedInputKeys, - `${label}.allowedInputKeys`, - ), - allowedOperations: expectStringArray( - record.allowedOperations, - `${label}.allowedOperations`, - ), - fixedArguments: expectStringArray( - record.fixedArguments, - `${label}.fixedArguments`, - ), - }; -} - -function decodeWorkflowCapabilityWorkflowStep( - value: unknown, - label = 'WorkflowCapabilityWorkflowStep', -): WorkflowCapabilityWorkflowStep { - const record = expectRecord(value, label); - return { - id: expectString(record.id, `${label}.id`), - type: expectString(record.type, `${label}.type`), - next: expectString(record.next, `${label}.next`), - }; -} - -function decodeWorkflowCapabilityWorkflow( - value: unknown, - label = 'WorkflowCapabilityWorkflow', -): WorkflowCapabilityWorkflow { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - description: expectString(record.description, `${label}.description`), - source: expectString(record.source, `${label}.source`), - closedWorldMode: expectBoolean( - record.closedWorldMode, - `${label}.closedWorldMode`, - ), - requiresLlmProvider: expectBoolean( - record.requiresLlmProvider, - `${label}.requiresLlmProvider`, - ), - primitives: expectStringArray(record.primitives, `${label}.primitives`), - requiredConnectors: expectStringArray( - record.requiredConnectors, - `${label}.requiredConnectors`, - ), - workflowCalls: expectStringArray( - record.workflowCalls, - `${label}.workflowCalls`, - ), - steps: expectArray( - record.steps, - `${label}.steps`, - decodeWorkflowCapabilityWorkflowStep, - ), - }; -} - -function decodeWorkflowCapabilities( - value: unknown, - label = 'WorkflowCapabilities', -): WorkflowCapabilities { - const record = expectRecord(value, label); - return { - schemaVersion: expectString(record.schemaVersion, `${label}.schemaVersion`), - generatedAtUtc: expectString(record.generatedAtUtc, `${label}.generatedAtUtc`), - primitives: expectArray( - record.primitives, - `${label}.primitives`, - decodeWorkflowPrimitiveCapability, - ), - connectors: expectArray( - record.connectors, - `${label}.connectors`, - decodeWorkflowConnectorCapability, - ), - workflows: expectArray( - record.workflows, - `${label}.workflows`, - decodeWorkflowCapabilityWorkflow, - ), - }; -} - -function decodeWorkflowAuthoringRetryPolicy( - value: unknown, - label = 'WorkflowAuthoringRetryPolicy', -): WorkflowAuthoringRetryPolicy { - const record = expectRecord(value, label); - return { - maxAttempts: expectNumber(record.maxAttempts, `${label}.maxAttempts`), - backoff: expectString(record.backoff, `${label}.backoff`), - delayMs: expectNumber(record.delayMs, `${label}.delayMs`), - }; -} - -function decodeWorkflowAuthoringErrorPolicy( - value: unknown, - label = 'WorkflowAuthoringErrorPolicy', -): WorkflowAuthoringErrorPolicy { - const record = expectRecord(value, label); - return { - strategy: expectString(record.strategy, `${label}.strategy`), - fallbackStep: expectNullableString( - record.fallbackStep, - `${label}.fallbackStep`, - ), - defaultOutput: expectNullableString( - record.defaultOutput, - `${label}.defaultOutput`, - ), - }; -} - -function decodeWorkflowAuthoringRole( - value: unknown, - label = 'WorkflowAuthoringRole', -): WorkflowAuthoringRole { - const record = expectRecord(value, label); - return { - id: expectString(record.id, `${label}.id`), - name: expectString(record.name, `${label}.name`), - systemPrompt: expectString(record.systemPrompt, `${label}.systemPrompt`), - provider: expectNullableString(record.provider, `${label}.provider`), - model: expectNullableString(record.model, `${label}.model`), - temperature: expectNullableNumber(record.temperature, `${label}.temperature`), - maxTokens: expectNullableNumber(record.maxTokens, `${label}.maxTokens`), - maxToolRounds: expectNullableNumber( - record.maxToolRounds, - `${label}.maxToolRounds`, - ), - maxHistoryMessages: expectNullableNumber( - record.maxHistoryMessages, - `${label}.maxHistoryMessages`, - ), - streamBufferCapacity: expectNullableNumber( - record.streamBufferCapacity, - `${label}.streamBufferCapacity`, - ), - eventModules: expectStringArray(record.eventModules, `${label}.eventModules`), - eventRoutes: expectString(record.eventRoutes, `${label}.eventRoutes`), - connectors: expectStringArray(record.connectors, `${label}.connectors`), - }; -} - -function decodeWorkflowAuthoringStep( - value: unknown, - label = 'WorkflowAuthoringStep', -): WorkflowAuthoringStep { - const record = expectRecord(value, label); - return { - id: expectString(record.id, `${label}.id`), - type: expectString(record.type, `${label}.type`), - targetRole: expectString(record.targetRole, `${label}.targetRole`), - parameters: expectStringRecord(record.parameters, `${label}.parameters`), - next: expectNullableString(record.next, `${label}.next`), - branches: expectStringRecord(record.branches, `${label}.branches`), - children: expectArray( - record.children, - `${label}.children`, - decodeWorkflowAuthoringStep, - ), - retry: - record.retry === null - ? null - : decodeWorkflowAuthoringRetryPolicy(record.retry, `${label}.retry`), - onError: - record.onError === null - ? null - : decodeWorkflowAuthoringErrorPolicy(record.onError, `${label}.onError`), - timeoutMs: expectNullableNumber(record.timeoutMs, `${label}.timeoutMs`), - }; -} - -function decodeWorkflowAuthoringDefinition( - value: unknown, - label = 'WorkflowAuthoringDefinition', -): WorkflowAuthoringDefinition { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - description: expectString(record.description, `${label}.description`), - closedWorldMode: expectBoolean( - record.closedWorldMode, - `${label}.closedWorldMode`, - ), - roles: expectArray(record.roles, `${label}.roles`, decodeWorkflowAuthoringRole), - steps: expectArray(record.steps, `${label}.steps`, decodeWorkflowAuthoringStep), - }; -} - -function decodeWorkflowAuthoringEdge( - value: unknown, - label = 'WorkflowAuthoringEdge', -): WorkflowAuthoringEdge { - const record = expectRecord(value, label); - return { - from: expectString(record.from, `${label}.from`), - to: expectString(record.to, `${label}.to`), - label: expectString(record.label, `${label}.label`), - }; -} - -function decodePlaygroundWorkflowParseResult( - value: unknown, - label = 'PlaygroundWorkflowParseResult', -): PlaygroundWorkflowParseResult { - const record = expectRecord(value, label); - return { - valid: expectBoolean(record.valid, `${label}.valid`), - error: expectNullableString(record.error, `${label}.error`), - errors: expectStringArray(record.errors, `${label}.errors`), - definition: - record.definition === null - ? null - : decodeWorkflowAuthoringDefinition( - record.definition, - `${label}.definition`, - ), - edges: expectArray(record.edges, `${label}.edges`, decodeWorkflowAuthoringEdge), - }; -} - -function decodePlaygroundWorkflowSaveResult( - value: unknown, - label = 'PlaygroundWorkflowSaveResult', -): PlaygroundWorkflowSaveResult { - const record = expectRecord(value, label); - return { - saved: expectBoolean(record.saved, `${label}.saved`), - filename: expectString(record.filename, `${label}.filename`), - savedPath: expectString(record.savedPath, `${label}.savedPath`), - workflowName: expectString(record.workflowName, `${label}.workflowName`), - overwritten: expectBoolean(record.overwritten, `${label}.overwritten`), - savedSource: expectString(record.savedSource, `${label}.savedSource`), - effectiveSource: expectString( - record.effectiveSource, - `${label}.effectiveSource`, - ), - effectivePath: expectString(record.effectivePath, `${label}.effectivePath`), - }; -} - -function decodeWorkflowPrimitiveParameterDescriptor( - value: unknown, - label = 'WorkflowPrimitiveParameterDescriptor', -): WorkflowPrimitiveParameterDescriptor { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - type: expectString(record.type, `${label}.type`), - required: expectBoolean(record.required, `${label}.required`), - description: expectString(record.description, `${label}.description`), - default: expectString(record.default, `${label}.default`), - enumValues: expectStringArray(record.enumValues, `${label}.enumValues`), - }; -} - -function decodeWorkflowPrimitiveDescriptor( - value: unknown, - label = 'WorkflowPrimitiveDescriptor', -): WorkflowPrimitiveDescriptor { - const record = expectRecord(value, label); - return { - name: expectString(record.name, `${label}.name`), - aliases: expectStringArray(record.aliases, `${label}.aliases`), - category: expectString(record.category, `${label}.category`), - description: expectString(record.description, `${label}.description`), - parameters: expectArray( - record.parameters, - `${label}.parameters`, - decodeWorkflowPrimitiveParameterDescriptor, - ), - exampleWorkflows: expectStringArray( - record.exampleWorkflows, - `${label}.exampleWorkflows`, - ), - }; -} - -function decodeWorkflowLlmStatus( - value: unknown, - label = 'WorkflowLlmStatus', -): WorkflowLlmStatus { - const record = expectRecord(value, label); - return { - available: expectBoolean(record.available, `${label}.available`), - provider: expectNullableString(record.provider, `${label}.provider`), - model: expectNullableString(record.model, `${label}.model`), - providers: expectStringArray(record.providers, `${label}.providers`), - }; -} - -function decodeWorkflowActorSnapshot( - value: unknown, - label = 'WorkflowActorSnapshot', -): WorkflowActorSnapshot { - const record = expectRecord(value, label); - return { - actorId: expectString(record.actorId, `${label}.actorId`), - workflowName: expectString(record.workflowName, `${label}.workflowName`), - lastCommandId: expectString(record.lastCommandId, `${label}.lastCommandId`), - stateVersion: expectNumber(record.stateVersion, `${label}.stateVersion`), - lastEventId: expectString(record.lastEventId, `${label}.lastEventId`), - lastUpdatedAt: expectString(record.lastUpdatedAt, `${label}.lastUpdatedAt`), - lastSuccess: expectNullableBoolean(record.lastSuccess, `${label}.lastSuccess`), - lastOutput: expectString(record.lastOutput, `${label}.lastOutput`), - lastError: expectString(record.lastError, `${label}.lastError`), - totalSteps: expectNumber(record.totalSteps, `${label}.totalSteps`), - requestedSteps: expectNumber( - record.requestedSteps, - `${label}.requestedSteps`, - ), - completedSteps: expectNumber( - record.completedSteps, - `${label}.completedSteps`, - ), - roleReplyCount: expectNumber(record.roleReplyCount, `${label}.roleReplyCount`), - }; -} - -function decodeWorkflowActorTimelineItem( - value: unknown, - label = 'WorkflowActorTimelineItem', -): WorkflowActorTimelineItem { - const record = expectRecord(value, label); - return { - timestamp: expectString(record.timestamp, `${label}.timestamp`), - stage: expectString(record.stage, `${label}.stage`), - message: expectString(record.message, `${label}.message`), - agentId: expectString(record.agentId, `${label}.agentId`), - stepId: expectString(record.stepId, `${label}.stepId`), - stepType: expectString(record.stepType, `${label}.stepType`), - eventType: expectString(record.eventType, `${label}.eventType`), - data: expectStringRecord(record.data, `${label}.data`), - }; -} - -function decodeWorkflowActorGraphNode( - value: unknown, - label = 'WorkflowActorGraphNode', -): WorkflowActorGraphNode { - const record = expectRecord(value, label); - return { - nodeId: expectString(record.nodeId, `${label}.nodeId`), - nodeType: expectString(record.nodeType, `${label}.nodeType`), - updatedAt: expectString(record.updatedAt, `${label}.updatedAt`), - properties: expectStringRecord(record.properties, `${label}.properties`), - }; -} - -function decodeWorkflowActorGraphEdge( - value: unknown, - label = 'WorkflowActorGraphEdge', -): WorkflowActorGraphEdge { - const record = expectRecord(value, label); - return { - edgeId: expectString(record.edgeId, `${label}.edgeId`), - fromNodeId: expectString(record.fromNodeId, `${label}.fromNodeId`), - toNodeId: expectString(record.toNodeId, `${label}.toNodeId`), - edgeType: expectString(record.edgeType, `${label}.edgeType`), - updatedAt: expectString(record.updatedAt, `${label}.updatedAt`), - properties: expectStringRecord(record.properties, `${label}.properties`), - }; -} - -function decodeWorkflowActorGraphSubgraph( - value: unknown, - label = 'WorkflowActorGraphSubgraph', -): WorkflowActorGraphSubgraph { - const record = expectRecord(value, label); - return { - rootNodeId: expectString(record.rootNodeId, `${label}.rootNodeId`), - nodes: expectArray(record.nodes, `${label}.nodes`, decodeWorkflowActorGraphNode), - edges: expectArray(record.edges, `${label}.edges`, decodeWorkflowActorGraphEdge), - }; -} - -function decodeWorkflowActorGraphEnrichedSnapshot( - value: unknown, - label = 'WorkflowActorGraphEnrichedSnapshot', -): WorkflowActorGraphEnrichedSnapshot { - const record = expectRecord(value, label); - return { - snapshot: decodeWorkflowActorSnapshot(record.snapshot, `${label}.snapshot`), - subgraph: decodeWorkflowActorGraphSubgraph( - record.subgraph, - `${label}.subgraph`, - ), - }; -} - -function decodeWorkflowResumeResponse( - value: unknown, - label = 'WorkflowResumeResponse', -): WorkflowResumeResponse { - const record = expectRecord(value, label); - return { - accepted: expectBoolean(record.accepted, `${label}.accepted`), - actorId: expectOptionalString(record.actorId, `${label}.actorId`), - runId: expectOptionalString(record.runId, `${label}.runId`), - stepId: expectOptionalString(record.stepId, `${label}.stepId`), - commandId: expectOptionalString(record.commandId, `${label}.commandId`), - }; -} - -function decodeWorkflowSignalResponse( - value: unknown, - label = 'WorkflowSignalResponse', -): WorkflowSignalResponse { - const record = expectRecord(value, label); - return { - accepted: expectBoolean(record.accepted, `${label}.accepted`), - actorId: expectOptionalString(record.actorId, `${label}.actorId`), - runId: expectOptionalString(record.runId, `${label}.runId`), - signalName: expectOptionalString(record.signalName, `${label}.signalName`), - stepId: expectOptionalString(record.stepId, `${label}.stepId`), - commandId: expectOptionalString(record.commandId, `${label}.commandId`), - }; -} - -export const decodeWorkflowAgentSummaries: Decoder = ( - value, -) => expectArray(value, 'WorkflowAgentSummary[]', decodeWorkflowAgentSummary); - -export const decodeWorkflowNames: Decoder = (value) => - expectStringArray(value, 'WorkflowNames'); - -export const decodeWorkflowCatalogItems: Decoder = ( - value, -) => expectArray(value, 'WorkflowCatalogItem[]', decodeWorkflowCatalogItem); - -export const decodeConfigurationSourceStatusResponse: Decoder = ( - value, -) => decodeConfigurationSourceStatus(value); - -export const decodeConfigurationWorkflowFilesResponse: Decoder = ( - value, -) => { - const record = expectRecord(value, 'ConfigurationWorkflowFilesResponse'); - return expectArray( - record.workflows, - 'ConfigurationWorkflowFilesResponse.workflows', - decodeConfigurationWorkflowFile, - ); -}; - -export const decodeConfigurationWorkflowFileDetailResponse: Decoder = ( - value, -) => { - const record = expectRecord(value, 'ConfigurationWorkflowFileDetailResponse'); - return decodeConfigurationWorkflowFileDetail( - record.workflow, - 'ConfigurationWorkflowFileDetailResponse.workflow', - ); -}; - -export const decodeConfigurationWorkflowFileMutationResponse: Decoder = ( - value, -) => { - const record = expectRecord(value, 'ConfigurationWorkflowFileMutationResponse'); - return decodeConfigurationWorkflowFile( - record.workflow, - 'ConfigurationWorkflowFileMutationResponse.workflow', - ); -}; - -export const decodeConfigurationRawDocumentResponse: Decoder = ( - value, -) => decodeConfigurationRawDocument(value); - -export const decodeConfigurationCollectionRawDocumentResponse: Decoder = ( - value, -) => decodeConfigurationCollectionRawDocument(value); - -export const decodeConfigurationValidationResultResponse: Decoder = ( - value, -) => decodeConfigurationValidationResult(value); - -export const decodeConfigurationMcpServersResponse: Decoder = ( - value, -) => { - const record = expectRecord(value, 'ConfigurationMcpServersResponse'); - return expectArray( - record.servers, - 'ConfigurationMcpServersResponse.servers', - decodeConfigurationMcpServer, - ); -}; - -export const decodeConfigurationMcpServerMutationResponse: Decoder = ( - value, -) => { - const record = expectRecord(value, 'ConfigurationMcpServerMutationResponse'); - return decodeConfigurationMcpServer( - record.server, - 'ConfigurationMcpServerMutationResponse.server', - ); -}; - -export const decodeConfigurationLlmApiKeyStatusResponse: Decoder = ( - value, -) => decodeConfigurationLlmApiKeyStatus(value); - -export const decodeConfigurationSecretValueStatusResponse: Decoder = ( - value, -) => decodeConfigurationSecretValueStatus(value); - -export const decodeConfigurationEmbeddingsStatusResponse: Decoder = ( - value, -) => { - const record = expectRecord(value, 'ConfigurationEmbeddingsStatusResponse'); - return decodeConfigurationEmbeddingsStatus( - record.embeddings, - 'ConfigurationEmbeddingsStatusResponse.embeddings', - ); -}; - -export const decodeConfigurationWebSearchStatusResponse: Decoder = ( - value, -) => { - const record = expectRecord(value, 'ConfigurationWebSearchStatusResponse'); - return decodeConfigurationWebSearchStatus( - record.webSearch, - 'ConfigurationWebSearchStatusResponse.webSearch', - ); -}; - -export const decodeConfigurationSkillsMpStatusResponse: Decoder = ( - value, -) => decodeConfigurationSkillsMpStatus(value); - -export const decodeConfigurationSecp256k1StatusResponse: Decoder = ( - value, -) => decodeConfigurationSecp256k1Status(value); - -export const decodeConfigurationSecp256k1GenerateResponse: Decoder = ( - value, -) => decodeConfigurationSecp256k1GenerateResult(value); - -export const decodeConfigurationLlmProbeResultResponse: Decoder = ( - value, -) => decodeConfigurationLlmProbeResult(value); - -export const decodeConfigurationLlmProviderTypesResponse: Decoder = ( - value, -) => { - const record = expectRecord(value, 'ConfigurationLlmProviderTypesResponse'); - return expectArray( - record.providers, - 'ConfigurationLlmProviderTypesResponse.providers', - decodeConfigurationLlmProviderType, - ); -}; - -export const decodeConfigurationLlmInstancesResponse: Decoder = ( - value, -) => { - const record = expectRecord(value, 'ConfigurationLlmInstancesResponse'); - return expectArray( - record.instances, - 'ConfigurationLlmInstancesResponse.instances', - decodeConfigurationLlmInstance, - ); -}; - -export const decodeConfigurationLlmDefaultResponse: Decoder = (value) => { - const record = expectRecord(value, 'ConfigurationLlmDefaultResponse'); - return expectString(record.providerName, 'ConfigurationLlmDefaultResponse.providerName'); -}; - -export const decodeWorkflowCapabilitiesResponse: Decoder = ( - value, -) => decodeWorkflowCapabilities(value); - -export const decodePlaygroundWorkflowParseResponse: Decoder = ( - value, -) => decodePlaygroundWorkflowParseResult(value); - -export const decodePlaygroundWorkflowSaveResponse: Decoder = ( - value, -) => decodePlaygroundWorkflowSaveResult(value); - -export const decodeWorkflowPrimitiveDescriptorsResponse: Decoder = ( - value, -) => expectArray(value, 'WorkflowPrimitiveDescriptor[]', decodeWorkflowPrimitiveDescriptor); - -export const decodeWorkflowLlmStatusResponse: Decoder = ( - value, -) => decodeWorkflowLlmStatus(value); - -export const decodeWorkflowCatalogItemDetailResponse: Decoder = ( - value, -) => decodeWorkflowCatalogItemDetail(value); - -export const decodeWorkflowActorSnapshotResponse: Decoder = ( - value, -) => decodeWorkflowActorSnapshot(value); - -export const decodeWorkflowActorTimelineResponse: Decoder = ( - value, -) => expectArray(value, 'WorkflowActorTimelineItem[]', decodeWorkflowActorTimelineItem); - -export const decodeWorkflowActorGraphEnrichedResponse: Decoder = ( - value, -) => decodeWorkflowActorGraphEnrichedSnapshot(value); - -export const decodeWorkflowActorGraphEdgesResponse: Decoder = ( - value, -) => expectArray(value, 'WorkflowActorGraphEdge[]', decodeWorkflowActorGraphEdge); - -export const decodeWorkflowActorGraphSubgraphResponse: Decoder = ( - value, -) => decodeWorkflowActorGraphSubgraph(value); - -export const decodeWorkflowResumeResponseBody: Decoder = ( - value, -) => decodeWorkflowResumeResponse(value); - -export const decodeWorkflowSignalResponseBody: Decoder = ( - value, -) => decodeWorkflowSignalResponse(value); +export * from "./decodeUtils"; +export * from "./configurationDecoders"; +export * from "./runtimeDecoders"; diff --git a/apps/aevatar-console-web/src/shared/api/governanceApi.ts b/apps/aevatar-console-web/src/shared/api/governanceApi.ts new file mode 100644 index 00000000..816f1266 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/governanceApi.ts @@ -0,0 +1,535 @@ +import { requestJson, withQuery } from "./http/client"; +import { + expectArray, + expectRecord, + normalizeEnumValue, + readBoolean, + readString, + readStringArray, +} from "./http/decoders"; +import type { + ActivationCapabilityView, + BoundConnectorReference, + BoundSecretReference, + BoundServiceReference, + ServiceBindingCatalogSnapshot, + ServiceBindingSnapshot, + ServiceEndpointCatalogSnapshot, + ServiceEndpointExposureSnapshot, + ServicePolicyCatalogSnapshot, + ServicePolicySnapshot, +} from "@/shared/models/governance"; +import type { + ServiceIdentity, + ServiceIdentityQuery, +} from "@/shared/models/services"; + +const serviceEndpointKindMap = { + "0": "unspecified", + "1": "command", + "2": "chat", + service_endpoint_kind_unspecified: "unspecified", + service_endpoint_kind_command: "command", + service_endpoint_kind_chat: "chat", + unspecified: "unspecified", + command: "command", + chat: "chat", +}; + +const bindingKindMap = { + "0": "unspecified", + "1": "service", + "2": "connector", + "3": "secret", + service_binding_kind_unspecified: "unspecified", + service_binding_kind_service: "service", + service_binding_kind_connector: "connector", + service_binding_kind_secret: "secret", + unspecified: "unspecified", + service: "service", + connector: "connector", + secret: "secret", +}; + +const exposureKindMap = { + "0": "unspecified", + "1": "internal", + "2": "public", + "3": "disabled", + service_endpoint_exposure_kind_unspecified: "unspecified", + service_endpoint_exposure_kind_internal: "internal", + service_endpoint_exposure_kind_public: "public", + service_endpoint_exposure_kind_disabled: "disabled", + unspecified: "unspecified", + internal: "internal", + public: "public", + disabled: "disabled", +}; + +function decodeServiceIdentity( + value: unknown, + label = "ServiceIdentity" +): ServiceIdentity { + const record = expectRecord(value, label); + return { + tenantId: readString(record, ["tenantId", "TenantId"], `${label}.tenantId`), + appId: readString(record, ["appId", "AppId"], `${label}.appId`), + namespace: readString( + record, + ["namespace", "Namespace"], + `${label}.namespace` + ), + serviceId: readString( + record, + ["serviceId", "ServiceId"], + `${label}.serviceId` + ), + }; +} + +function decodeBoundServiceReference( + value: unknown, + label = "BoundServiceReference" +): BoundServiceReference { + const record = expectRecord(value, label); + return { + identity: decodeServiceIdentity( + record.identity ?? record.Identity, + `${label}.identity` + ), + endpointId: readString( + record, + ["endpointId", "EndpointId"], + `${label}.endpointId` + ), + }; +} + +function decodeBoundConnectorReference( + value: unknown, + label = "BoundConnectorReference" +): BoundConnectorReference { + const record = expectRecord(value, label); + return { + connectorType: readString( + record, + ["connectorType", "ConnectorType"], + `${label}.connectorType` + ), + connectorId: readString( + record, + ["connectorId", "ConnectorId"], + `${label}.connectorId` + ), + }; +} + +function decodeBoundSecretReference( + value: unknown, + label = "BoundSecretReference" +): BoundSecretReference { + const record = expectRecord(value, label); + return { + secretName: readString( + record, + ["secretName", "SecretName"], + `${label}.secretName` + ), + }; +} + +function decodeServiceBindingSnapshot( + value: unknown, + label = "ServiceBindingSnapshot" +): ServiceBindingSnapshot { + const record = expectRecord(value, label); + const serviceRef = record.serviceRef ?? record.ServiceRef; + const connectorRef = record.connectorRef ?? record.ConnectorRef; + const secretRef = record.secretRef ?? record.SecretRef; + + return { + bindingId: readString( + record, + ["bindingId", "BindingId"], + `${label}.bindingId` + ), + displayName: readString( + record, + ["displayName", "DisplayName"], + `${label}.displayName` + ), + bindingKind: normalizeEnumValue( + record.bindingKind ?? record.BindingKind, + `${label}.bindingKind`, + bindingKindMap + ), + policyIds: readStringArray( + record, + ["policyIds", "PolicyIds"], + `${label}.policyIds` + ), + retired: readBoolean(record, ["retired", "Retired"], `${label}.retired`), + serviceRef: + serviceRef === null || serviceRef === undefined + ? null + : decodeBoundServiceReference(serviceRef, `${label}.serviceRef`), + connectorRef: + connectorRef === null || connectorRef === undefined + ? null + : decodeBoundConnectorReference(connectorRef, `${label}.connectorRef`), + secretRef: + secretRef === null || secretRef === undefined + ? null + : decodeBoundSecretReference(secretRef, `${label}.secretRef`), + }; +} + +function decodeServiceBindingCatalogSnapshot( + value: unknown, + label = "ServiceBindingCatalogSnapshot" +): ServiceBindingCatalogSnapshot { + const record = expectRecord(value, label); + return { + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + bindings: expectArray( + record.bindings ?? record.Bindings, + `${label}.bindings`, + decodeServiceBindingSnapshot + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeServicePolicySnapshot( + value: unknown, + label = "ServicePolicySnapshot" +): ServicePolicySnapshot { + const record = expectRecord(value, label); + return { + policyId: readString(record, ["policyId", "PolicyId"], `${label}.policyId`), + displayName: readString( + record, + ["displayName", "DisplayName"], + `${label}.displayName` + ), + activationRequiredBindingIds: readStringArray( + record, + ["activationRequiredBindingIds", "ActivationRequiredBindingIds"], + `${label}.activationRequiredBindingIds` + ), + invokeAllowedCallerServiceKeys: readStringArray( + record, + ["invokeAllowedCallerServiceKeys", "InvokeAllowedCallerServiceKeys"], + `${label}.invokeAllowedCallerServiceKeys` + ), + invokeRequiresActiveDeployment: readBoolean( + record, + ["invokeRequiresActiveDeployment", "InvokeRequiresActiveDeployment"], + `${label}.invokeRequiresActiveDeployment` + ), + retired: readBoolean(record, ["retired", "Retired"], `${label}.retired`), + }; +} + +function decodeServicePolicyCatalogSnapshot( + value: unknown, + label = "ServicePolicyCatalogSnapshot" +): ServicePolicyCatalogSnapshot { + const record = expectRecord(value, label); + return { + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + policies: expectArray( + record.policies ?? record.Policies, + `${label}.policies`, + decodeServicePolicySnapshot + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeServiceEndpointExposureSnapshot( + value: unknown, + label = "ServiceEndpointExposureSnapshot" +): ServiceEndpointExposureSnapshot { + const record = expectRecord(value, label); + return { + endpointId: readString( + record, + ["endpointId", "EndpointId"], + `${label}.endpointId` + ), + displayName: readString( + record, + ["displayName", "DisplayName"], + `${label}.displayName` + ), + kind: normalizeEnumValue( + record.kind ?? record.Kind, + `${label}.kind`, + serviceEndpointKindMap + ), + requestTypeUrl: readString( + record, + ["requestTypeUrl", "RequestTypeUrl"], + `${label}.requestTypeUrl` + ), + responseTypeUrl: readString( + record, + ["responseTypeUrl", "ResponseTypeUrl"], + `${label}.responseTypeUrl` + ), + description: readString( + record, + ["description", "Description"], + `${label}.description` + ), + exposureKind: normalizeEnumValue( + record.exposureKind ?? record.ExposureKind, + `${label}.exposureKind`, + exposureKindMap + ), + policyIds: readStringArray( + record, + ["policyIds", "PolicyIds"], + `${label}.policyIds` + ), + }; +} + +function decodeServiceEndpointCatalogSnapshot( + value: unknown, + label = "ServiceEndpointCatalogSnapshot" +): ServiceEndpointCatalogSnapshot { + const record = expectRecord(value, label); + return { + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + endpoints: expectArray( + record.endpoints ?? record.Endpoints, + `${label}.endpoints`, + decodeServiceEndpointExposureSnapshot + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeActivationCapabilityView( + value: unknown, + label = "ActivationCapabilityView" +): ActivationCapabilityView { + const record = expectRecord(value, label); + + const bindingsSource = record.bindings ?? record.Bindings ?? []; + const endpointsSource = record.endpoints ?? record.Endpoints ?? []; + const policiesSource = record.policies ?? record.Policies ?? []; + const missingPolicyIdsSource = + record.missingPolicyIds ?? record.MissingPolicyIds ?? []; + + return { + identity: decodeServiceIdentity( + record.identity ?? record.Identity, + `${label}.identity` + ), + revisionId: readString( + record, + ["revisionId", "RevisionId"], + `${label}.revisionId` + ), + bindings: expectArray( + bindingsSource, + `${label}.bindings`, + (entry, nestedLabel) => { + const binding = expectRecord( + entry, + nestedLabel ?? "ServiceBindingSpec" + ); + return { + bindingId: readString( + binding, + ["bindingId", "BindingId"], + `${nestedLabel}.bindingId` + ), + displayName: readString( + binding, + ["displayName", "DisplayName"], + `${nestedLabel}.displayName` + ), + bindingKind: normalizeEnumValue( + binding.bindingKind ?? binding.BindingKind, + `${nestedLabel}.bindingKind`, + bindingKindMap + ), + policyIds: readStringArray( + binding, + ["policyIds", "PolicyIds"], + `${nestedLabel}.policyIds` + ), + retired: false, + serviceRef: + binding.serviceRef ?? binding.ServiceRef + ? decodeBoundServiceReference( + binding.serviceRef ?? binding.ServiceRef, + `${nestedLabel}.serviceRef` + ) + : null, + connectorRef: + binding.connectorRef ?? binding.ConnectorRef + ? decodeBoundConnectorReference( + binding.connectorRef ?? binding.ConnectorRef, + `${nestedLabel}.connectorRef` + ) + : null, + secretRef: + binding.secretRef ?? binding.SecretRef + ? decodeBoundSecretReference( + binding.secretRef ?? binding.SecretRef, + `${nestedLabel}.secretRef` + ) + : null, + }; + } + ), + endpoints: expectArray( + endpointsSource, + `${label}.endpoints`, + decodeServiceEndpointExposureSnapshot + ), + policies: expectArray( + policiesSource, + `${label}.policies`, + (entry, nestedLabel) => { + const policy = expectRecord(entry, nestedLabel ?? "ServicePolicySpec"); + return { + policyId: readString( + policy, + ["policyId", "PolicyId"], + `${nestedLabel}.policyId` + ), + displayName: readString( + policy, + ["displayName", "DisplayName"], + `${nestedLabel}.displayName` + ), + activationRequiredBindingIds: readStringArray( + policy, + ["activationRequiredBindingIds", "ActivationRequiredBindingIds"], + `${nestedLabel}.activationRequiredBindingIds` + ), + invokeAllowedCallerServiceKeys: readStringArray( + policy, + [ + "invokeAllowedCallerServiceKeys", + "InvokeAllowedCallerServiceKeys", + ], + `${nestedLabel}.invokeAllowedCallerServiceKeys` + ), + invokeRequiresActiveDeployment: readBoolean( + policy, + [ + "invokeRequiresActiveDeployment", + "InvokeRequiresActiveDeployment", + ], + `${nestedLabel}.invokeRequiresActiveDeployment` + ), + retired: false, + }; + } + ), + missingPolicyIds: Array.isArray(missingPolicyIdsSource) + ? missingPolicyIdsSource.map((entry, index) => + readString({ entry }, "entry", `${label}.missingPolicyIds[${index}]`) + ) + : [], + }; +} + +function buildIdentityQuery(query: ServiceIdentityQuery) { + return { + tenantId: query.tenantId?.trim(), + appId: query.appId?.trim(), + namespace: query.namespace?.trim(), + }; +} + +export const governanceApi = { + getBindings( + serviceId: string, + query: ServiceIdentityQuery + ): Promise { + return requestJson( + withQuery( + `/api/services/${encodeURIComponent(serviceId)}/bindings`, + buildIdentityQuery(query) + ), + (value) => + value === null ? null : decodeServiceBindingCatalogSnapshot(value) + ); + }, + + getPolicies( + serviceId: string, + query: ServiceIdentityQuery + ): Promise { + return requestJson( + withQuery( + `/api/services/${encodeURIComponent(serviceId)}/policies`, + buildIdentityQuery(query) + ), + (value) => + value === null ? null : decodeServicePolicyCatalogSnapshot(value) + ); + }, + + getEndpointCatalog( + serviceId: string, + query: ServiceIdentityQuery + ): Promise { + return requestJson( + withQuery( + `/api/services/${encodeURIComponent(serviceId)}/endpoint-catalog`, + buildIdentityQuery(query) + ), + (value) => + value === null ? null : decodeServiceEndpointCatalogSnapshot(value) + ); + }, + + getActivationCapability( + serviceId: string, + query: ServiceIdentityQuery & { revisionId?: string } + ): Promise { + return requestJson( + withQuery( + `/api/services/${encodeURIComponent(serviceId)}:activation-capability`, + { + ...buildIdentityQuery(query), + revisionId: query.revisionId?.trim(), + } + ), + decodeActivationCapabilityView + ); + }, +}; diff --git a/apps/aevatar-console-web/src/shared/api/http/client.ts b/apps/aevatar-console-web/src/shared/api/http/client.ts new file mode 100644 index 00000000..8d2bf905 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/http/client.ts @@ -0,0 +1,84 @@ +import { authFetch } from "@/shared/auth/fetch"; +import type { Decoder } from "../decodeUtils"; + +export type QueryValue = + | string + | number + | boolean + | Array + | null + | undefined; + +const JSON_HEADERS = { + Accept: "application/json", + "Content-Type": "application/json", +}; + +async function readError(response: Response): Promise { + const text = await response.text(); + if (!text) { + return `HTTP ${response.status}`; + } + + try { + const payload = JSON.parse(text) as { + message?: string; + error?: string; + code?: string; + }; + return payload.message || payload.error || payload.code || text; + } catch { + return text; + } +} + +export function withQuery( + path: string, + query?: Record +): string { + if (!query) { + return path; + } + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === "") { + continue; + } + + if (Array.isArray(value)) { + for (const entry of value) { + params.append(key, String(entry)); + } + continue; + } + + params.set(key, String(value)); + } + + if (params.size === 0) { + return path; + } + + return `${path}?${params.toString()}`; +} + +export async function requestJson( + input: string, + decoder: Decoder, + init?: RequestInit +): Promise { + const response = await authFetch(input, init); + if (!response.ok) { + throw new Error(await readError(response)); + } + + return decoder(await response.json()); +} + +export function jsonBody(body: unknown): RequestInit { + return { + body: JSON.stringify(body), + headers: JSON_HEADERS, + }; +} diff --git a/apps/aevatar-console-web/src/shared/api/http/decoders.ts b/apps/aevatar-console-web/src/shared/api/http/decoders.ts new file mode 100644 index 00000000..67e5e33d --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/http/decoders.ts @@ -0,0 +1,184 @@ +export type Decoder = (value: unknown, label?: string) => T; + +export type JsonRecord = Record; + +function normalizeKeys(keys: string | string[]): string[] { + return Array.isArray(keys) ? keys : [keys]; +} + +function findValue(record: JsonRecord, keys: string | string[]): unknown { + for (const key of normalizeKeys(keys)) { + if (key in record) { + return record[key]; + } + } + + return undefined; +} + +export function expectRecord(value: unknown, label: string): JsonRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + + return value as JsonRecord; +} + +export function expectArray( + value: unknown, + label: string, + decoder: Decoder +): T[] { + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array.`); + } + + return value.map((entry, index) => decoder(entry, `${label}[${index}]`)); +} + +export function expectString(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new Error(`${label} must be a string.`); + } + + return value; +} + +export function expectNumber(value: unknown, label: string): number { + if (typeof value !== "number" || Number.isNaN(value)) { + throw new Error(`${label} must be a number.`); + } + + return value; +} + +export function expectBoolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new Error(`${label} must be a boolean.`); + } + + return value; +} + +export function readString( + record: JsonRecord, + keys: string | string[], + label: string +): string { + return expectString(findValue(record, keys), label); +} + +export function readNumber( + record: JsonRecord, + keys: string | string[], + label: string +): number { + return expectNumber(findValue(record, keys), label); +} + +export function readBoolean( + record: JsonRecord, + keys: string | string[], + label: string +): boolean { + return expectBoolean(findValue(record, keys), label); +} + +export function readNullableString( + record: JsonRecord, + keys: string | string[], + label: string +): string | null { + const value = findValue(record, keys); + return value === null || value === undefined + ? null + : expectString(value, label); +} + +export function readOptionalString( + record: JsonRecord, + keys: string | string[], + label: string +): string | undefined { + const value = findValue(record, keys); + return value === null || value === undefined + ? undefined + : expectString(value, label); +} + +export function readStringArray( + record: JsonRecord, + keys: string | string[], + label: string +): string[] { + const value = findValue(record, keys); + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array.`); + } + + return value.map((entry, index) => expectString(entry, `${label}[${index}]`)); +} + +export function readStringRecord( + record: JsonRecord, + keys: string | string[], + label: string +): Record { + const value = findValue(record, keys); + if (value === undefined || value === null) { + return {}; + } + + const nested = expectRecord(value, label); + return Object.fromEntries( + Object.entries(nested).map(([key, entry]) => [ + key, + expectString(entry, `${label}.${key}`), + ]) + ); +} + +export function readOptionalRecord( + record: JsonRecord, + keys: string | string[], + label: string +): JsonRecord | undefined { + const value = findValue(record, keys); + return value === undefined || value === null + ? undefined + : expectRecord(value, label); +} + +export function readOptionalArray( + record: JsonRecord, + keys: string | string[], + label: string, + decoder: Decoder +): T[] { + const value = findValue(record, keys); + return value === undefined || value === null + ? [] + : expectArray(value, label, decoder); +} + +export function normalizeEnumValue( + value: unknown, + label: string, + mapping: Record +): string { + if (typeof value === "number") { + return mapping[String(value)] ?? String(value); + } + + if (typeof value === "string") { + const direct = mapping[value]; + if (direct) { + return direct; + } + + const normalized = value.trim().toLowerCase(); + return mapping[normalized] ?? value; + } + + throw new Error(`${label} must be a string or number.`); +} diff --git a/apps/aevatar-console-web/src/shared/api/runtimeActorsApi.ts b/apps/aevatar-console-web/src/shared/api/runtimeActorsApi.ts new file mode 100644 index 00000000..41d2d49a --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/runtimeActorsApi.ts @@ -0,0 +1,99 @@ +import { + decodeWorkflowActorGraphEdgesResponse, + decodeWorkflowActorGraphEnrichedResponse, + decodeWorkflowActorGraphSubgraphResponse, + decodeWorkflowActorSnapshotResponse, + decodeWorkflowActorTimelineResponse, +} from "./runtimeDecoders"; +import { requestJson, withQuery } from "./http/client"; +import type { + WorkflowActorGraphEdge, + WorkflowActorGraphEnrichedSnapshot, + WorkflowActorGraphSubgraph, + WorkflowActorSnapshot, + WorkflowActorTimelineItem, +} from "@/shared/models/runtime/actors"; + +export type ActorGraphDirection = "Both" | "Outbound" | "Inbound"; + +type ActorGraphOptions = { + depth?: number; + take?: number; + direction?: ActorGraphDirection; + edgeTypes?: string[]; +}; + +type ActorTimelineOptions = { + take?: number; +}; + +function normalizeEdgeTypes(edgeTypes?: string[]): string[] { + return (edgeTypes ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +export const runtimeActorsApi = { + getActorSnapshot(actorId: string): Promise { + return requestJson( + `/api/actors/${encodeURIComponent(actorId)}`, + decodeWorkflowActorSnapshotResponse + ); + }, + + getActorTimeline( + actorId: string, + options?: ActorTimelineOptions + ): Promise { + return requestJson( + withQuery(`/api/actors/${encodeURIComponent(actorId)}/timeline`, { + take: options?.take, + }), + decodeWorkflowActorTimelineResponse + ); + }, + + getActorGraphEnriched( + actorId: string, + options?: ActorGraphOptions + ): Promise { + return requestJson( + withQuery(`/api/actors/${encodeURIComponent(actorId)}/graph-enriched`, { + depth: options?.depth, + take: options?.take, + direction: options?.direction, + edgeTypes: normalizeEdgeTypes(options?.edgeTypes), + }), + decodeWorkflowActorGraphEnrichedResponse + ); + }, + + getActorGraphEdges( + actorId: string, + options?: Omit + ): Promise { + return requestJson( + withQuery(`/api/actors/${encodeURIComponent(actorId)}/graph-edges`, { + take: options?.take, + direction: options?.direction, + edgeTypes: normalizeEdgeTypes(options?.edgeTypes), + }), + decodeWorkflowActorGraphEdgesResponse + ); + }, + + getActorGraphSubgraph( + actorId: string, + options?: ActorGraphOptions + ): Promise { + return requestJson( + withQuery(`/api/actors/${encodeURIComponent(actorId)}/graph-subgraph`, { + depth: options?.depth, + take: options?.take, + direction: options?.direction, + edgeTypes: normalizeEdgeTypes(options?.edgeTypes), + }), + decodeWorkflowActorGraphSubgraphResponse + ); + }, +}; diff --git a/apps/aevatar-console-web/src/shared/api/runtimeCatalogApi.ts b/apps/aevatar-console-web/src/shared/api/runtimeCatalogApi.ts new file mode 100644 index 00000000..4787a912 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/runtimeCatalogApi.ts @@ -0,0 +1,27 @@ +import { + decodeWorkflowCatalogItemDetailResponse, + decodeWorkflowCatalogItems, + decodeWorkflowNames, +} from "./runtimeDecoders"; +import { requestJson } from "./http/client"; +import type { + WorkflowCatalogItem, + WorkflowCatalogItemDetail, +} from "@/shared/models/runtime/catalog"; + +export const runtimeCatalogApi = { + listWorkflowNames(): Promise { + return requestJson("/api/workflows", decodeWorkflowNames); + }, + + listWorkflowCatalog(): Promise { + return requestJson("/api/workflow-catalog", decodeWorkflowCatalogItems); + }, + + getWorkflowDetail(workflowName: string): Promise { + return requestJson( + `/api/workflows/${encodeURIComponent(workflowName)}`, + decodeWorkflowCatalogItemDetailResponse + ); + }, +}; diff --git a/apps/aevatar-console-web/src/shared/api/runtimeDecoders.ts b/apps/aevatar-console-web/src/shared/api/runtimeDecoders.ts new file mode 100644 index 00000000..f392ee7d --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/runtimeDecoders.ts @@ -0,0 +1,810 @@ +import type { + WorkflowResumeResponse, + WorkflowSignalResponse, +} from "@aevatar-react-sdk/types"; +import type { + PlaygroundWorkflowParseResult, + PlaygroundWorkflowSaveResult, + WorkflowAuthoringDefinition, + WorkflowAuthoringEdge, + WorkflowAuthoringErrorPolicy, + WorkflowAuthoringRetryPolicy, + WorkflowAuthoringRole, + WorkflowAuthoringStep, +} from "@/shared/models/runtime/authoring"; +import type { + WorkflowActorGraphEdge, + WorkflowActorGraphEnrichedSnapshot, + WorkflowActorGraphNode, + WorkflowActorGraphSubgraph, + WorkflowActorSnapshot, + WorkflowActorTimelineItem, +} from "@/shared/models/runtime/actors"; +import type { + WorkflowCatalogChildStep, + WorkflowCatalogDefinition, + WorkflowCatalogEdge, + WorkflowCatalogItem, + WorkflowCatalogItemDetail, + WorkflowCatalogRole, + WorkflowCatalogStep, +} from "@/shared/models/runtime/catalog"; +import type { + WorkflowAgentSummary, + WorkflowCapabilities, + WorkflowCapabilityParameter, + WorkflowCapabilityWorkflow, + WorkflowCapabilityWorkflowStep, + WorkflowConnectorCapability, + WorkflowLlmStatus, + WorkflowPrimitiveCapability, + WorkflowPrimitiveDescriptor, + WorkflowPrimitiveParameterDescriptor, +} from "@/shared/models/runtime/query"; +import { + type Decoder, + expectArray, + expectBoolean, + expectNullableBoolean, + expectNullableNumber, + expectNullableString, + expectNumber, + expectOptionalString, + expectRecord, + expectString, + expectStringArray, + expectStringRecord, +} from "./decodeUtils"; + +function decodeWorkflowCatalogItem( + value: unknown, + label = "WorkflowCatalogItem" +): WorkflowCatalogItem { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + description: expectString(record.description, `${label}.description`), + category: expectString(record.category, `${label}.category`), + group: expectString(record.group, `${label}.group`), + groupLabel: expectString(record.groupLabel, `${label}.groupLabel`), + sortOrder: expectNumber(record.sortOrder, `${label}.sortOrder`), + source: expectString(record.source, `${label}.source`), + sourceLabel: expectString(record.sourceLabel, `${label}.sourceLabel`), + showInLibrary: expectBoolean( + record.showInLibrary, + `${label}.showInLibrary` + ), + isPrimitiveExample: expectBoolean( + record.isPrimitiveExample, + `${label}.isPrimitiveExample` + ), + requiresLlmProvider: expectBoolean( + record.requiresLlmProvider, + `${label}.requiresLlmProvider` + ), + primitives: expectStringArray(record.primitives, `${label}.primitives`), + }; +} + +function decodeWorkflowCatalogRole( + value: unknown, + label = "WorkflowCatalogRole" +): WorkflowCatalogRole { + const record = expectRecord(value, label); + return { + id: expectString(record.id, `${label}.id`), + name: expectString(record.name, `${label}.name`), + systemPrompt: expectString(record.systemPrompt, `${label}.systemPrompt`), + provider: expectString(record.provider, `${label}.provider`), + model: expectString(record.model, `${label}.model`), + temperature: expectNullableNumber( + record.temperature, + `${label}.temperature` + ), + maxTokens: expectNullableNumber(record.maxTokens, `${label}.maxTokens`), + maxToolRounds: expectNullableNumber( + record.maxToolRounds, + `${label}.maxToolRounds` + ), + maxHistoryMessages: expectNullableNumber( + record.maxHistoryMessages, + `${label}.maxHistoryMessages` + ), + streamBufferCapacity: expectNullableNumber( + record.streamBufferCapacity, + `${label}.streamBufferCapacity` + ), + eventModules: expectStringArray( + record.eventModules, + `${label}.eventModules` + ), + eventRoutes: expectString(record.eventRoutes, `${label}.eventRoutes`), + connectors: expectStringArray(record.connectors, `${label}.connectors`), + }; +} + +function decodeWorkflowCatalogChildStep( + value: unknown, + label = "WorkflowCatalogChildStep" +): WorkflowCatalogChildStep { + const record = expectRecord(value, label); + return { + id: expectString(record.id, `${label}.id`), + type: expectString(record.type, `${label}.type`), + targetRole: expectString(record.targetRole, `${label}.targetRole`), + }; +} + +function decodeWorkflowCatalogStep( + value: unknown, + label = "WorkflowCatalogStep" +): WorkflowCatalogStep { + const record = expectRecord(value, label); + return { + id: expectString(record.id, `${label}.id`), + type: expectString(record.type, `${label}.type`), + targetRole: expectString(record.targetRole, `${label}.targetRole`), + parameters: expectStringRecord(record.parameters, `${label}.parameters`), + next: expectString(record.next, `${label}.next`), + branches: expectStringRecord(record.branches, `${label}.branches`), + children: expectArray( + record.children, + `${label}.children`, + decodeWorkflowCatalogChildStep + ), + }; +} + +function decodeWorkflowCatalogEdge( + value: unknown, + label = "WorkflowCatalogEdge" +): WorkflowCatalogEdge { + const record = expectRecord(value, label); + return { + from: expectString(record.from, `${label}.from`), + to: expectString(record.to, `${label}.to`), + label: expectString(record.label, `${label}.label`), + }; +} + +function decodeWorkflowCatalogDefinition( + value: unknown, + label = "WorkflowCatalogDefinition" +): WorkflowCatalogDefinition { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + description: expectString(record.description, `${label}.description`), + closedWorldMode: expectBoolean( + record.closedWorldMode, + `${label}.closedWorldMode` + ), + roles: expectArray( + record.roles, + `${label}.roles`, + decodeWorkflowCatalogRole + ), + steps: expectArray( + record.steps, + `${label}.steps`, + decodeWorkflowCatalogStep + ), + }; +} + +function decodeWorkflowCatalogItemDetail( + value: unknown, + label = "WorkflowCatalogItemDetail" +): WorkflowCatalogItemDetail { + const record = expectRecord(value, label); + return { + catalog: decodeWorkflowCatalogItem(record.catalog, `${label}.catalog`), + yaml: expectString(record.yaml, `${label}.yaml`), + definition: decodeWorkflowCatalogDefinition( + record.definition, + `${label}.definition` + ), + edges: expectArray( + record.edges, + `${label}.edges`, + decodeWorkflowCatalogEdge + ), + }; +} + +function decodeWorkflowAgentSummary( + value: unknown, + label = "WorkflowAgentSummary" +): WorkflowAgentSummary { + const record = expectRecord(value, label); + return { + id: expectString(record.id, `${label}.id`), + type: expectString(record.type, `${label}.type`), + description: expectString(record.description, `${label}.description`), + }; +} + +function decodeWorkflowCapabilityParameter( + value: unknown, + label = "WorkflowCapabilityParameter" +): WorkflowCapabilityParameter { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + type: expectString(record.type, `${label}.type`), + required: expectBoolean(record.required, `${label}.required`), + description: expectString(record.description, `${label}.description`), + default: expectString(record.default, `${label}.default`), + enum: expectStringArray(record.enum, `${label}.enum`), + }; +} + +function decodeWorkflowPrimitiveCapability( + value: unknown, + label = "WorkflowPrimitiveCapability" +): WorkflowPrimitiveCapability { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + aliases: expectStringArray(record.aliases, `${label}.aliases`), + category: expectString(record.category, `${label}.category`), + description: expectString(record.description, `${label}.description`), + closedWorldBlocked: expectBoolean( + record.closedWorldBlocked, + `${label}.closedWorldBlocked` + ), + runtimeModule: expectString(record.runtimeModule, `${label}.runtimeModule`), + parameters: expectArray( + record.parameters, + `${label}.parameters`, + decodeWorkflowCapabilityParameter + ), + }; +} + +function decodeWorkflowConnectorCapability( + value: unknown, + label = "WorkflowConnectorCapability" +): WorkflowConnectorCapability { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + type: expectString(record.type, `${label}.type`), + enabled: expectBoolean(record.enabled, `${label}.enabled`), + timeoutMs: expectNumber(record.timeoutMs, `${label}.timeoutMs`), + retry: expectNumber(record.retry, `${label}.retry`), + allowedInputKeys: expectStringArray( + record.allowedInputKeys, + `${label}.allowedInputKeys` + ), + allowedOperations: expectStringArray( + record.allowedOperations, + `${label}.allowedOperations` + ), + fixedArguments: expectStringArray( + record.fixedArguments, + `${label}.fixedArguments` + ), + }; +} + +function decodeWorkflowCapabilityWorkflowStep( + value: unknown, + label = "WorkflowCapabilityWorkflowStep" +): WorkflowCapabilityWorkflowStep { + const record = expectRecord(value, label); + return { + id: expectString(record.id, `${label}.id`), + type: expectString(record.type, `${label}.type`), + next: expectString(record.next, `${label}.next`), + }; +} + +function decodeWorkflowCapabilityWorkflow( + value: unknown, + label = "WorkflowCapabilityWorkflow" +): WorkflowCapabilityWorkflow { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + description: expectString(record.description, `${label}.description`), + source: expectString(record.source, `${label}.source`), + closedWorldMode: expectBoolean( + record.closedWorldMode, + `${label}.closedWorldMode` + ), + requiresLlmProvider: expectBoolean( + record.requiresLlmProvider, + `${label}.requiresLlmProvider` + ), + primitives: expectStringArray(record.primitives, `${label}.primitives`), + requiredConnectors: expectStringArray( + record.requiredConnectors, + `${label}.requiredConnectors` + ), + workflowCalls: expectStringArray( + record.workflowCalls, + `${label}.workflowCalls` + ), + steps: expectArray( + record.steps, + `${label}.steps`, + decodeWorkflowCapabilityWorkflowStep + ), + }; +} + +function decodeWorkflowCapabilities( + value: unknown, + label = "WorkflowCapabilities" +): WorkflowCapabilities { + const record = expectRecord(value, label); + return { + schemaVersion: expectString(record.schemaVersion, `${label}.schemaVersion`), + generatedAtUtc: expectString( + record.generatedAtUtc, + `${label}.generatedAtUtc` + ), + primitives: expectArray( + record.primitives, + `${label}.primitives`, + decodeWorkflowPrimitiveCapability + ), + connectors: expectArray( + record.connectors, + `${label}.connectors`, + decodeWorkflowConnectorCapability + ), + workflows: expectArray( + record.workflows, + `${label}.workflows`, + decodeWorkflowCapabilityWorkflow + ), + }; +} + +function decodeWorkflowAuthoringRetryPolicy( + value: unknown, + label = "WorkflowAuthoringRetryPolicy" +): WorkflowAuthoringRetryPolicy { + const record = expectRecord(value, label); + return { + maxAttempts: expectNumber(record.maxAttempts, `${label}.maxAttempts`), + backoff: expectString(record.backoff, `${label}.backoff`), + delayMs: expectNumber(record.delayMs, `${label}.delayMs`), + }; +} + +function decodeWorkflowAuthoringErrorPolicy( + value: unknown, + label = "WorkflowAuthoringErrorPolicy" +): WorkflowAuthoringErrorPolicy { + const record = expectRecord(value, label); + return { + strategy: expectString(record.strategy, `${label}.strategy`), + fallbackStep: expectNullableString( + record.fallbackStep, + `${label}.fallbackStep` + ), + defaultOutput: expectNullableString( + record.defaultOutput, + `${label}.defaultOutput` + ), + }; +} + +function decodeWorkflowAuthoringRole( + value: unknown, + label = "WorkflowAuthoringRole" +): WorkflowAuthoringRole { + const record = expectRecord(value, label); + return { + id: expectString(record.id, `${label}.id`), + name: expectString(record.name, `${label}.name`), + systemPrompt: expectString(record.systemPrompt, `${label}.systemPrompt`), + provider: expectNullableString(record.provider, `${label}.provider`), + model: expectNullableString(record.model, `${label}.model`), + temperature: expectNullableNumber( + record.temperature, + `${label}.temperature` + ), + maxTokens: expectNullableNumber(record.maxTokens, `${label}.maxTokens`), + maxToolRounds: expectNullableNumber( + record.maxToolRounds, + `${label}.maxToolRounds` + ), + maxHistoryMessages: expectNullableNumber( + record.maxHistoryMessages, + `${label}.maxHistoryMessages` + ), + streamBufferCapacity: expectNullableNumber( + record.streamBufferCapacity, + `${label}.streamBufferCapacity` + ), + eventModules: expectStringArray( + record.eventModules, + `${label}.eventModules` + ), + eventRoutes: expectString(record.eventRoutes, `${label}.eventRoutes`), + connectors: expectStringArray(record.connectors, `${label}.connectors`), + }; +} + +function decodeWorkflowAuthoringStep( + value: unknown, + label = "WorkflowAuthoringStep" +): WorkflowAuthoringStep { + const record = expectRecord(value, label); + return { + id: expectString(record.id, `${label}.id`), + type: expectString(record.type, `${label}.type`), + targetRole: expectString(record.targetRole, `${label}.targetRole`), + parameters: expectStringRecord(record.parameters, `${label}.parameters`), + next: expectNullableString(record.next, `${label}.next`), + branches: expectStringRecord(record.branches, `${label}.branches`), + children: expectArray( + record.children, + `${label}.children`, + decodeWorkflowAuthoringStep + ), + retry: + record.retry === null + ? null + : decodeWorkflowAuthoringRetryPolicy(record.retry, `${label}.retry`), + onError: + record.onError === null + ? null + : decodeWorkflowAuthoringErrorPolicy( + record.onError, + `${label}.onError` + ), + timeoutMs: expectNullableNumber(record.timeoutMs, `${label}.timeoutMs`), + }; +} + +function decodeWorkflowAuthoringDefinition( + value: unknown, + label = "WorkflowAuthoringDefinition" +): WorkflowAuthoringDefinition { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + description: expectString(record.description, `${label}.description`), + closedWorldMode: expectBoolean( + record.closedWorldMode, + `${label}.closedWorldMode` + ), + roles: expectArray( + record.roles, + `${label}.roles`, + decodeWorkflowAuthoringRole + ), + steps: expectArray( + record.steps, + `${label}.steps`, + decodeWorkflowAuthoringStep + ), + }; +} + +function decodeWorkflowAuthoringEdge( + value: unknown, + label = "WorkflowAuthoringEdge" +): WorkflowAuthoringEdge { + const record = expectRecord(value, label); + return { + from: expectString(record.from, `${label}.from`), + to: expectString(record.to, `${label}.to`), + label: expectString(record.label, `${label}.label`), + }; +} + +function decodePlaygroundWorkflowParseResult( + value: unknown, + label = "PlaygroundWorkflowParseResult" +): PlaygroundWorkflowParseResult { + const record = expectRecord(value, label); + return { + valid: expectBoolean(record.valid, `${label}.valid`), + error: expectNullableString(record.error, `${label}.error`), + errors: expectStringArray(record.errors, `${label}.errors`), + definition: + record.definition === null + ? null + : decodeWorkflowAuthoringDefinition( + record.definition, + `${label}.definition` + ), + edges: expectArray( + record.edges, + `${label}.edges`, + decodeWorkflowAuthoringEdge + ), + }; +} + +function decodePlaygroundWorkflowSaveResult( + value: unknown, + label = "PlaygroundWorkflowSaveResult" +): PlaygroundWorkflowSaveResult { + const record = expectRecord(value, label); + return { + saved: expectBoolean(record.saved, `${label}.saved`), + filename: expectString(record.filename, `${label}.filename`), + savedPath: expectString(record.savedPath, `${label}.savedPath`), + workflowName: expectString(record.workflowName, `${label}.workflowName`), + overwritten: expectBoolean(record.overwritten, `${label}.overwritten`), + savedSource: expectString(record.savedSource, `${label}.savedSource`), + effectiveSource: expectString( + record.effectiveSource, + `${label}.effectiveSource` + ), + effectivePath: expectString(record.effectivePath, `${label}.effectivePath`), + }; +} + +function decodeWorkflowPrimitiveParameterDescriptor( + value: unknown, + label = "WorkflowPrimitiveParameterDescriptor" +): WorkflowPrimitiveParameterDescriptor { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + type: expectString(record.type, `${label}.type`), + required: expectBoolean(record.required, `${label}.required`), + description: expectString(record.description, `${label}.description`), + default: expectString(record.default, `${label}.default`), + enumValues: expectStringArray(record.enumValues, `${label}.enumValues`), + }; +} + +function decodeWorkflowPrimitiveDescriptor( + value: unknown, + label = "WorkflowPrimitiveDescriptor" +): WorkflowPrimitiveDescriptor { + const record = expectRecord(value, label); + return { + name: expectString(record.name, `${label}.name`), + aliases: expectStringArray(record.aliases, `${label}.aliases`), + category: expectString(record.category, `${label}.category`), + description: expectString(record.description, `${label}.description`), + parameters: expectArray( + record.parameters, + `${label}.parameters`, + decodeWorkflowPrimitiveParameterDescriptor + ), + exampleWorkflows: expectStringArray( + record.exampleWorkflows, + `${label}.exampleWorkflows` + ), + }; +} + +function decodeWorkflowLlmStatus( + value: unknown, + label = "WorkflowLlmStatus" +): WorkflowLlmStatus { + const record = expectRecord(value, label); + return { + available: expectBoolean(record.available, `${label}.available`), + provider: expectNullableString(record.provider, `${label}.provider`), + model: expectNullableString(record.model, `${label}.model`), + providers: expectStringArray(record.providers, `${label}.providers`), + }; +} + +function decodeWorkflowActorSnapshot( + value: unknown, + label = "WorkflowActorSnapshot" +): WorkflowActorSnapshot { + const record = expectRecord(value, label); + return { + actorId: expectString(record.actorId, `${label}.actorId`), + workflowName: expectString(record.workflowName, `${label}.workflowName`), + lastCommandId: expectString(record.lastCommandId, `${label}.lastCommandId`), + stateVersion: expectNumber(record.stateVersion, `${label}.stateVersion`), + lastEventId: expectString(record.lastEventId, `${label}.lastEventId`), + lastUpdatedAt: expectString(record.lastUpdatedAt, `${label}.lastUpdatedAt`), + lastSuccess: expectNullableBoolean( + record.lastSuccess, + `${label}.lastSuccess` + ), + lastOutput: expectString(record.lastOutput, `${label}.lastOutput`), + lastError: expectString(record.lastError, `${label}.lastError`), + totalSteps: expectNumber(record.totalSteps, `${label}.totalSteps`), + requestedSteps: expectNumber( + record.requestedSteps, + `${label}.requestedSteps` + ), + completedSteps: expectNumber( + record.completedSteps, + `${label}.completedSteps` + ), + roleReplyCount: expectNumber( + record.roleReplyCount, + `${label}.roleReplyCount` + ), + }; +} + +function decodeWorkflowActorTimelineItem( + value: unknown, + label = "WorkflowActorTimelineItem" +): WorkflowActorTimelineItem { + const record = expectRecord(value, label); + return { + timestamp: expectString(record.timestamp, `${label}.timestamp`), + stage: expectString(record.stage, `${label}.stage`), + message: expectString(record.message, `${label}.message`), + agentId: expectString(record.agentId, `${label}.agentId`), + stepId: expectString(record.stepId, `${label}.stepId`), + stepType: expectString(record.stepType, `${label}.stepType`), + eventType: expectString(record.eventType, `${label}.eventType`), + data: expectStringRecord(record.data, `${label}.data`), + }; +} + +function decodeWorkflowActorGraphNode( + value: unknown, + label = "WorkflowActorGraphNode" +): WorkflowActorGraphNode { + const record = expectRecord(value, label); + return { + nodeId: expectString(record.nodeId, `${label}.nodeId`), + nodeType: expectString(record.nodeType, `${label}.nodeType`), + updatedAt: expectString(record.updatedAt, `${label}.updatedAt`), + properties: expectStringRecord(record.properties, `${label}.properties`), + }; +} + +function decodeWorkflowActorGraphEdge( + value: unknown, + label = "WorkflowActorGraphEdge" +): WorkflowActorGraphEdge { + const record = expectRecord(value, label); + return { + edgeId: expectString(record.edgeId, `${label}.edgeId`), + fromNodeId: expectString(record.fromNodeId, `${label}.fromNodeId`), + toNodeId: expectString(record.toNodeId, `${label}.toNodeId`), + edgeType: expectString(record.edgeType, `${label}.edgeType`), + updatedAt: expectString(record.updatedAt, `${label}.updatedAt`), + properties: expectStringRecord(record.properties, `${label}.properties`), + }; +} + +function decodeWorkflowActorGraphSubgraph( + value: unknown, + label = "WorkflowActorGraphSubgraph" +): WorkflowActorGraphSubgraph { + const record = expectRecord(value, label); + return { + rootNodeId: expectString(record.rootNodeId, `${label}.rootNodeId`), + nodes: expectArray( + record.nodes, + `${label}.nodes`, + decodeWorkflowActorGraphNode + ), + edges: expectArray( + record.edges, + `${label}.edges`, + decodeWorkflowActorGraphEdge + ), + }; +} + +function decodeWorkflowActorGraphEnrichedSnapshot( + value: unknown, + label = "WorkflowActorGraphEnrichedSnapshot" +): WorkflowActorGraphEnrichedSnapshot { + const record = expectRecord(value, label); + return { + snapshot: decodeWorkflowActorSnapshot(record.snapshot, `${label}.snapshot`), + subgraph: decodeWorkflowActorGraphSubgraph( + record.subgraph, + `${label}.subgraph` + ), + }; +} + +function decodeWorkflowResumeResponse( + value: unknown, + label = "WorkflowResumeResponse" +): WorkflowResumeResponse { + const record = expectRecord(value, label); + return { + accepted: expectBoolean(record.accepted, `${label}.accepted`), + actorId: expectOptionalString(record.actorId, `${label}.actorId`), + runId: expectOptionalString(record.runId, `${label}.runId`), + stepId: expectOptionalString(record.stepId, `${label}.stepId`), + commandId: expectOptionalString(record.commandId, `${label}.commandId`), + }; +} + +function decodeWorkflowSignalResponse( + value: unknown, + label = "WorkflowSignalResponse" +): WorkflowSignalResponse { + const record = expectRecord(value, label); + return { + accepted: expectBoolean(record.accepted, `${label}.accepted`), + actorId: expectOptionalString(record.actorId, `${label}.actorId`), + runId: expectOptionalString(record.runId, `${label}.runId`), + signalName: expectOptionalString(record.signalName, `${label}.signalName`), + stepId: expectOptionalString(record.stepId, `${label}.stepId`), + commandId: expectOptionalString(record.commandId, `${label}.commandId`), + }; +} + +export const decodeWorkflowAgentSummaries: Decoder = ( + value +) => expectArray(value, "WorkflowAgentSummary[]", decodeWorkflowAgentSummary); + +export const decodeWorkflowNames: Decoder = (value) => + expectStringArray(value, "WorkflowNames"); + +export const decodeWorkflowCatalogItems: Decoder = ( + value +) => expectArray(value, "WorkflowCatalogItem[]", decodeWorkflowCatalogItem); + +export const decodeWorkflowCapabilitiesResponse: Decoder< + WorkflowCapabilities +> = (value) => decodeWorkflowCapabilities(value); + +export const decodePlaygroundWorkflowParseResponse: Decoder< + PlaygroundWorkflowParseResult +> = (value) => decodePlaygroundWorkflowParseResult(value); + +export const decodePlaygroundWorkflowSaveResponse: Decoder< + PlaygroundWorkflowSaveResult +> = (value) => decodePlaygroundWorkflowSaveResult(value); + +export const decodeWorkflowPrimitiveDescriptorsResponse: Decoder< + WorkflowPrimitiveDescriptor[] +> = (value) => + expectArray( + value, + "WorkflowPrimitiveDescriptor[]", + decodeWorkflowPrimitiveDescriptor + ); + +export const decodeWorkflowLlmStatusResponse: Decoder = ( + value +) => decodeWorkflowLlmStatus(value); + +export const decodeWorkflowCatalogItemDetailResponse: Decoder< + WorkflowCatalogItemDetail +> = (value) => decodeWorkflowCatalogItemDetail(value); + +export const decodeWorkflowActorSnapshotResponse: Decoder< + WorkflowActorSnapshot +> = (value) => decodeWorkflowActorSnapshot(value); + +export const decodeWorkflowActorTimelineResponse: Decoder< + WorkflowActorTimelineItem[] +> = (value) => + expectArray( + value, + "WorkflowActorTimelineItem[]", + decodeWorkflowActorTimelineItem + ); + +export const decodeWorkflowActorGraphEnrichedResponse: Decoder< + WorkflowActorGraphEnrichedSnapshot +> = (value) => decodeWorkflowActorGraphEnrichedSnapshot(value); + +export const decodeWorkflowActorGraphEdgesResponse: Decoder< + WorkflowActorGraphEdge[] +> = (value) => + expectArray(value, "WorkflowActorGraphEdge[]", decodeWorkflowActorGraphEdge); + +export const decodeWorkflowActorGraphSubgraphResponse: Decoder< + WorkflowActorGraphSubgraph +> = (value) => decodeWorkflowActorGraphSubgraph(value); + +export const decodeWorkflowResumeResponseBody: Decoder< + WorkflowResumeResponse +> = (value) => decodeWorkflowResumeResponse(value); + +export const decodeWorkflowSignalResponseBody: Decoder< + WorkflowSignalResponse +> = (value) => decodeWorkflowSignalResponse(value); diff --git a/apps/aevatar-console-web/src/shared/api/runtimeQueryApi.ts b/apps/aevatar-console-web/src/shared/api/runtimeQueryApi.ts new file mode 100644 index 00000000..923adf2c --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/runtimeQueryApi.ts @@ -0,0 +1,28 @@ +import { + decodeWorkflowAgentSummaries, + decodeWorkflowCapabilitiesResponse, + decodeWorkflowPrimitiveDescriptorsResponse, +} from "./runtimeDecoders"; +import { requestJson } from "./http/client"; +import type { + WorkflowAgentSummary, + WorkflowCapabilities, + WorkflowPrimitiveDescriptor, +} from "@/shared/models/runtime/query"; + +export const runtimeQueryApi = { + listAgents(): Promise { + return requestJson("/api/agents", decodeWorkflowAgentSummaries); + }, + + getCapabilities(): Promise { + return requestJson("/api/capabilities", decodeWorkflowCapabilitiesResponse); + }, + + listPrimitives(): Promise { + return requestJson( + "/api/primitives", + decodeWorkflowPrimitiveDescriptorsResponse + ); + }, +}; diff --git a/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.test.ts b/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.test.ts new file mode 100644 index 00000000..167ef345 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.test.ts @@ -0,0 +1,58 @@ +import { runtimeRunsApi } from "./runtimeRunsApi"; + +describe("runtimeRunsApi", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it("surfaces non-OK streamChat responses from the runtime boundary", async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => '{"message":"invalid workflow yaml"}', + } satisfies Partial); + + global.fetch = fetchMock as typeof global.fetch; + + await expect( + runtimeRunsApi.streamChat( + { + prompt: "Run it", + workflowYamls: ["name: broken"], + }, + new AbortController().signal + ) + ).rejects.toThrow("invalid workflow yaml"); + }); + + it("decodes resume responses from the runtime boundary", async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + accepted: true, + actorId: "actor-1", + runId: "run-1", + stepId: "step-1", + }), + } satisfies Partial); + + global.fetch = fetchMock as typeof global.fetch; + + await expect( + runtimeRunsApi.resume({ + actorId: "actor-1", + runId: "run-1", + stepId: "step-1", + approved: true, + }) + ).resolves.toEqual({ + accepted: true, + actorId: "actor-1", + runId: "run-1", + stepId: "step-1", + }); + }); +}); diff --git a/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.ts b/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.ts new file mode 100644 index 00000000..29450365 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.ts @@ -0,0 +1,124 @@ +import type { + ChatRunRequest, + WorkflowResumeRequest, + WorkflowResumeResponse, + WorkflowSignalRequest, + WorkflowSignalResponse, +} from "@aevatar-react-sdk/types"; +import { authFetch } from "@/shared/auth/fetch"; +import { + decodeWorkflowResumeResponseBody, + decodeWorkflowSignalResponseBody, +} from "./runtimeDecoders"; +import { requestJson } from "./http/client"; + +const JSON_HEADERS = { + "Content-Type": "application/json", + Accept: "application/json", +}; + +function trimOptional(value?: string): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function compactObject>(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined) + ) as T; +} + +async function readError(response: Response): Promise { + const text = await response.text(); + if (!text) { + return `HTTP ${response.status}`; + } + + try { + const payload = JSON.parse(text) as { + message?: string; + error?: string; + code?: string; + }; + return payload.message || payload.error || payload.code || text; + } catch { + return text; + } +} + +export const runtimeRunsApi = { + async streamChat( + request: ChatRunRequest, + signal: AbortSignal + ): Promise { + const response = await authFetch("/api/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify( + compactObject({ + prompt: request.prompt.trim(), + workflow: trimOptional(request.workflow), + agentId: trimOptional(request.agentId), + workflowYamls: + request.workflowYamls && request.workflowYamls.length > 0 + ? request.workflowYamls + : undefined, + metadata: request.metadata, + }) + ), + signal, + }); + + if (!response.ok) { + throw new Error(await readError(response)); + } + + return response; + }, + + resume(request: WorkflowResumeRequest): Promise { + return requestJson( + "/api/workflows/resume", + decodeWorkflowResumeResponseBody, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify( + compactObject({ + actorId: request.actorId, + runId: request.runId, + stepId: request.stepId, + commandId: trimOptional(request.commandId), + approved: request.approved, + userInput: trimOptional(request.userInput), + metadata: request.metadata, + }) + ), + } + ); + }, + + signal(request: WorkflowSignalRequest): Promise { + return requestJson( + "/api/workflows/signal", + decodeWorkflowSignalResponseBody, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify( + compactObject({ + actorId: request.actorId, + runId: request.runId, + signalName: request.signalName, + stepId: trimOptional(request.stepId), + commandId: trimOptional(request.commandId), + payload: trimOptional(request.payload), + }) + ), + } + ); + }, +}; diff --git a/apps/aevatar-console-web/src/shared/api/scopesApi.ts b/apps/aevatar-console-web/src/shared/api/scopesApi.ts new file mode 100644 index 00000000..c9e561d6 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/scopesApi.ts @@ -0,0 +1,325 @@ +import { + expectArray, + expectRecord, + readBoolean, + readOptionalRecord, + readString, + readStringArray, + readStringRecord, + type Decoder, +} from "./http/decoders"; +import { requestJson } from "./http/client"; +import type { + ScopeScriptCatalog, + ScopeScriptDetail, + ScopeScriptSource, + ScopeScriptSummary, + ScopeWorkflowDetail, + ScopeWorkflowSource, + ScopeWorkflowSummary, +} from "@/shared/models/scopes"; + +function decodeScopeWorkflowSummary( + value: unknown, + label = "ScopeWorkflowSummary" +): ScopeWorkflowSummary { + const record = expectRecord(value, label); + return { + scopeId: readString(record, ["scopeId", "ScopeId"], `${label}.scopeId`), + workflowId: readString( + record, + ["workflowId", "WorkflowId"], + `${label}.workflowId` + ), + displayName: readString( + record, + ["displayName", "DisplayName"], + `${label}.displayName` + ), + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + workflowName: readString( + record, + ["workflowName", "WorkflowName"], + `${label}.workflowName` + ), + actorId: readString(record, ["actorId", "ActorId"], `${label}.actorId`), + activeRevisionId: readString( + record, + ["activeRevisionId", "ActiveRevisionId"], + `${label}.activeRevisionId` + ), + deploymentId: readString( + record, + ["deploymentId", "DeploymentId"], + `${label}.deploymentId` + ), + deploymentStatus: readString( + record, + ["deploymentStatus", "DeploymentStatus"], + `${label}.deploymentStatus` + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeScopeWorkflowSource( + value: unknown, + label = "ScopeWorkflowSource" +): ScopeWorkflowSource { + const record = expectRecord(value, label); + const inlineWorkflowYamls = readOptionalRecord( + record, + ["inlineWorkflowYamls", "InlineWorkflowYamls"], + `${label}.inlineWorkflowYamls` + ); + + return { + workflowYaml: readString( + record, + ["workflowYaml", "WorkflowYaml"], + `${label}.workflowYaml` + ), + definitionActorId: readString( + record, + ["definitionActorId", "DefinitionActorId"], + `${label}.definitionActorId` + ), + inlineWorkflowYamls: inlineWorkflowYamls + ? readStringRecord( + { inlineWorkflowYamls }, + "inlineWorkflowYamls", + `${label}.inlineWorkflowYamls` + ) + : null, + }; +} + +function decodeScopeWorkflowDetail( + value: unknown, + label = "ScopeWorkflowDetail" +): ScopeWorkflowDetail { + const record = expectRecord(value, label); + const workflowValue = record.workflow ?? record.Workflow; + const sourceValue = record.source ?? record.Source; + + return { + available: readBoolean( + record, + ["available", "Available"], + `${label}.available` + ), + scopeId: readString(record, ["scopeId", "ScopeId"], `${label}.scopeId`), + workflow: + workflowValue === null || workflowValue === undefined + ? null + : decodeScopeWorkflowSummary(workflowValue, `${label}.workflow`), + source: + sourceValue === null || sourceValue === undefined + ? null + : decodeScopeWorkflowSource(sourceValue, `${label}.source`), + }; +} + +function decodeScopeScriptSummary( + value: unknown, + label = "ScopeScriptSummary" +): ScopeScriptSummary { + const record = expectRecord(value, label); + return { + scopeId: readString(record, ["scopeId", "ScopeId"], `${label}.scopeId`), + scriptId: readString(record, ["scriptId", "ScriptId"], `${label}.scriptId`), + catalogActorId: readString( + record, + ["catalogActorId", "CatalogActorId"], + `${label}.catalogActorId` + ), + definitionActorId: readString( + record, + ["definitionActorId", "DefinitionActorId"], + `${label}.definitionActorId` + ), + activeRevision: readString( + record, + ["activeRevision", "ActiveRevision"], + `${label}.activeRevision` + ), + activeSourceHash: readString( + record, + ["activeSourceHash", "ActiveSourceHash"], + `${label}.activeSourceHash` + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeScopeScriptSource( + value: unknown, + label = "ScopeScriptSource" +): ScopeScriptSource { + const record = expectRecord(value, label); + return { + sourceText: readString( + record, + ["sourceText", "SourceText"], + `${label}.sourceText` + ), + definitionActorId: readString( + record, + ["definitionActorId", "DefinitionActorId"], + `${label}.definitionActorId` + ), + revision: readString(record, ["revision", "Revision"], `${label}.revision`), + sourceHash: readString( + record, + ["sourceHash", "SourceHash"], + `${label}.sourceHash` + ), + }; +} + +function decodeScopeScriptDetail( + value: unknown, + label = "ScopeScriptDetail" +): ScopeScriptDetail { + const record = expectRecord(value, label); + const scriptValue = record.script ?? record.Script; + const sourceValue = record.source ?? record.Source; + + return { + available: readBoolean( + record, + ["available", "Available"], + `${label}.available` + ), + scopeId: readString(record, ["scopeId", "ScopeId"], `${label}.scopeId`), + script: + scriptValue === null || scriptValue === undefined + ? null + : decodeScopeScriptSummary(scriptValue, `${label}.script`), + source: + sourceValue === null || sourceValue === undefined + ? null + : decodeScopeScriptSource(sourceValue, `${label}.source`), + }; +} + +function decodeScopeScriptCatalog( + value: unknown, + label = "ScopeScriptCatalog" +): ScopeScriptCatalog { + const record = expectRecord(value, label); + return { + scriptId: readString(record, ["scriptId", "ScriptId"], `${label}.scriptId`), + activeRevision: readString( + record, + ["activeRevision", "ActiveRevision"], + `${label}.activeRevision` + ), + activeDefinitionActorId: readString( + record, + ["activeDefinitionActorId", "ActiveDefinitionActorId"], + `${label}.activeDefinitionActorId` + ), + activeSourceHash: readString( + record, + ["activeSourceHash", "ActiveSourceHash"], + `${label}.activeSourceHash` + ), + previousRevision: readString( + record, + ["previousRevision", "PreviousRevision"], + `${label}.previousRevision` + ), + revisionHistory: readStringArray( + record, + ["revisionHistory", "RevisionHistory"], + `${label}.revisionHistory` + ), + lastProposalId: readString( + record, + ["lastProposalId", "LastProposalId"], + `${label}.lastProposalId` + ), + catalogActorId: readString( + record, + ["catalogActorId", "CatalogActorId"], + `${label}.catalogActorId` + ), + scopeId: readString(record, ["scopeId", "ScopeId"], `${label}.scopeId`), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +const decodeScopeWorkflowSummaries: Decoder = (value) => + expectArray(value, "ScopeWorkflowSummary[]", decodeScopeWorkflowSummary); + +const decodeScopeScriptSummaries: Decoder = (value) => + expectArray(value, "ScopeScriptSummary[]", decodeScopeScriptSummary); + +export const scopesApi = { + listWorkflows(scopeId: string): Promise { + return requestJson( + `/api/scopes/${encodeURIComponent(scopeId)}/workflows`, + decodeScopeWorkflowSummaries + ); + }, + + getWorkflowDetail( + scopeId: string, + workflowId: string + ): Promise { + return requestJson( + `/api/scopes/${encodeURIComponent( + scopeId + )}/workflows/${encodeURIComponent(workflowId)}`, + decodeScopeWorkflowDetail + ); + }, + + listScripts(scopeId: string): Promise { + return requestJson( + `/api/scopes/${encodeURIComponent(scopeId)}/scripts`, + decodeScopeScriptSummaries + ); + }, + + getScriptDetail( + scopeId: string, + scriptId: string + ): Promise { + return requestJson( + `/api/scopes/${encodeURIComponent(scopeId)}/scripts/${encodeURIComponent( + scriptId + )}`, + decodeScopeScriptDetail + ); + }, + + getScriptCatalog( + scopeId: string, + scriptId: string + ): Promise { + return requestJson( + `/api/scopes/${encodeURIComponent(scopeId)}/scripts/${encodeURIComponent( + scriptId + )}/catalog`, + decodeScopeScriptCatalog + ); + }, +}; diff --git a/apps/aevatar-console-web/src/shared/api/servicesApi.ts b/apps/aevatar-console-web/src/shared/api/servicesApi.ts new file mode 100644 index 00000000..5161f8b4 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/servicesApi.ts @@ -0,0 +1,656 @@ +import { requestJson, withQuery } from "./http/client"; +import { + expectArray, + expectRecord, + normalizeEnumValue, + readNumber, + readNullableString, + readString, + readStringArray, + type Decoder, +} from "./http/decoders"; +import type { + ServiceCatalogSnapshot, + ServiceDeploymentCatalogSnapshot, + ServiceDeploymentSnapshot, + ServiceEndpointSnapshot, + ServiceIdentityQuery, + ServiceRevisionCatalogSnapshot, + ServiceRevisionSnapshot, + ServiceRolloutSnapshot, + ServiceRolloutStageSnapshot, + ServiceServingSetSnapshot, + ServiceServingTargetSnapshot, + ServiceTrafficEndpointSnapshot, + ServiceTrafficTargetSnapshot, + ServiceTrafficViewSnapshot, +} from "@/shared/models/services"; + +const serviceEndpointKindMap = { + "0": "unspecified", + "1": "command", + "2": "chat", + service_endpoint_kind_unspecified: "unspecified", + service_endpoint_kind_command: "command", + service_endpoint_kind_chat: "chat", + unspecified: "unspecified", + command: "command", + chat: "chat", +}; + +function decodeServiceEndpointSnapshot( + value: unknown, + label = "ServiceEndpointSnapshot" +): ServiceEndpointSnapshot { + const record = expectRecord(value, label); + return { + endpointId: readString( + record, + ["endpointId", "EndpointId"], + `${label}.endpointId` + ), + displayName: readString( + record, + ["displayName", "DisplayName"], + `${label}.displayName` + ), + kind: normalizeEnumValue( + record.kind ?? record.Kind, + `${label}.kind`, + serviceEndpointKindMap + ), + requestTypeUrl: readString( + record, + ["requestTypeUrl", "RequestTypeUrl"], + `${label}.requestTypeUrl` + ), + responseTypeUrl: readString( + record, + ["responseTypeUrl", "ResponseTypeUrl"], + `${label}.responseTypeUrl` + ), + description: readString( + record, + ["description", "Description"], + `${label}.description` + ), + }; +} + +function decodeServiceCatalogSnapshot( + value: unknown, + label = "ServiceCatalogSnapshot" +): ServiceCatalogSnapshot { + const record = expectRecord(value, label); + return { + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + tenantId: readString(record, ["tenantId", "TenantId"], `${label}.tenantId`), + appId: readString(record, ["appId", "AppId"], `${label}.appId`), + namespace: readString( + record, + ["namespace", "Namespace"], + `${label}.namespace` + ), + serviceId: readString( + record, + ["serviceId", "ServiceId"], + `${label}.serviceId` + ), + displayName: readString( + record, + ["displayName", "DisplayName"], + `${label}.displayName` + ), + defaultServingRevisionId: readString( + record, + ["defaultServingRevisionId", "DefaultServingRevisionId"], + `${label}.defaultServingRevisionId` + ), + activeServingRevisionId: readString( + record, + ["activeServingRevisionId", "ActiveServingRevisionId"], + `${label}.activeServingRevisionId` + ), + deploymentId: readString( + record, + ["deploymentId", "DeploymentId"], + `${label}.deploymentId` + ), + primaryActorId: readString( + record, + ["primaryActorId", "PrimaryActorId"], + `${label}.primaryActorId` + ), + deploymentStatus: readString( + record, + ["deploymentStatus", "DeploymentStatus"], + `${label}.deploymentStatus` + ), + endpoints: expectArray( + record.endpoints ?? record.Endpoints, + `${label}.endpoints`, + decodeServiceEndpointSnapshot + ), + policyIds: readStringArray( + record, + ["policyIds", "PolicyIds"], + `${label}.policyIds` + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeServiceRevisionSnapshot( + value: unknown, + label = "ServiceRevisionSnapshot" +): ServiceRevisionSnapshot { + const record = expectRecord(value, label); + return { + revisionId: readString( + record, + ["revisionId", "RevisionId"], + `${label}.revisionId` + ), + implementationKind: readString( + record, + ["implementationKind", "ImplementationKind"], + `${label}.implementationKind` + ), + status: readString(record, ["status", "Status"], `${label}.status`), + artifactHash: readString( + record, + ["artifactHash", "ArtifactHash"], + `${label}.artifactHash` + ), + failureReason: readString( + record, + ["failureReason", "FailureReason"], + `${label}.failureReason` + ), + endpoints: expectArray( + record.endpoints ?? record.Endpoints, + `${label}.endpoints`, + decodeServiceEndpointSnapshot + ), + createdAt: readNullableString( + record, + ["createdAt", "CreatedAt"], + `${label}.createdAt` + ), + preparedAt: readNullableString( + record, + ["preparedAt", "PreparedAt"], + `${label}.preparedAt` + ), + publishedAt: readNullableString( + record, + ["publishedAt", "PublishedAt"], + `${label}.publishedAt` + ), + retiredAt: readNullableString( + record, + ["retiredAt", "RetiredAt"], + `${label}.retiredAt` + ), + }; +} + +function decodeServiceRevisionCatalogSnapshot( + value: unknown, + label = "ServiceRevisionCatalogSnapshot" +): ServiceRevisionCatalogSnapshot { + const record = expectRecord(value, label); + return { + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + revisions: expectArray( + record.revisions ?? record.Revisions, + `${label}.revisions`, + decodeServiceRevisionSnapshot + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeServiceDeploymentSnapshot( + value: unknown, + label = "ServiceDeploymentSnapshot" +): ServiceDeploymentSnapshot { + const record = expectRecord(value, label); + return { + deploymentId: readString( + record, + ["deploymentId", "DeploymentId"], + `${label}.deploymentId` + ), + revisionId: readString( + record, + ["revisionId", "RevisionId"], + `${label}.revisionId` + ), + primaryActorId: readString( + record, + ["primaryActorId", "PrimaryActorId"], + `${label}.primaryActorId` + ), + status: readString(record, ["status", "Status"], `${label}.status`), + activatedAt: readNullableString( + record, + ["activatedAt", "ActivatedAt"], + `${label}.activatedAt` + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeServiceDeploymentCatalogSnapshot( + value: unknown, + label = "ServiceDeploymentCatalogSnapshot" +): ServiceDeploymentCatalogSnapshot { + const record = expectRecord(value, label); + return { + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + deployments: expectArray( + record.deployments ?? record.Deployments, + `${label}.deployments`, + decodeServiceDeploymentSnapshot + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeServiceServingTargetSnapshot( + value: unknown, + label = "ServiceServingTargetSnapshot" +): ServiceServingTargetSnapshot { + const record = expectRecord(value, label); + return { + deploymentId: readString( + record, + ["deploymentId", "DeploymentId"], + `${label}.deploymentId` + ), + revisionId: readString( + record, + ["revisionId", "RevisionId"], + `${label}.revisionId` + ), + primaryActorId: readString( + record, + ["primaryActorId", "PrimaryActorId"], + `${label}.primaryActorId` + ), + allocationWeight: readNumber( + record, + ["allocationWeight", "AllocationWeight"], + `${label}.allocationWeight` + ), + servingState: readString( + record, + ["servingState", "ServingState"], + `${label}.servingState` + ), + enabledEndpointIds: readStringArray( + record, + ["enabledEndpointIds", "EnabledEndpointIds"], + `${label}.enabledEndpointIds` + ), + }; +} + +function decodeServiceServingSetSnapshot( + value: unknown, + label = "ServiceServingSetSnapshot" +): ServiceServingSetSnapshot { + const record = expectRecord(value, label); + return { + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + generation: readNumber( + record, + ["generation", "Generation"], + `${label}.generation` + ), + activeRolloutId: readString( + record, + ["activeRolloutId", "ActiveRolloutId"], + `${label}.activeRolloutId` + ), + targets: expectArray( + record.targets ?? record.Targets, + `${label}.targets`, + decodeServiceServingTargetSnapshot + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeServiceRolloutStageSnapshot( + value: unknown, + label = "ServiceRolloutStageSnapshot" +): ServiceRolloutStageSnapshot { + const record = expectRecord(value, label); + return { + stageId: readString(record, ["stageId", "StageId"], `${label}.stageId`), + stageIndex: readNumber( + record, + ["stageIndex", "StageIndex"], + `${label}.stageIndex` + ), + targets: expectArray( + record.targets ?? record.Targets, + `${label}.targets`, + decodeServiceServingTargetSnapshot + ), + }; +} + +function decodeServiceRolloutSnapshot( + value: unknown, + label = "ServiceRolloutSnapshot" +): ServiceRolloutSnapshot { + const record = expectRecord(value, label); + return { + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + rolloutId: readString( + record, + ["rolloutId", "RolloutId"], + `${label}.rolloutId` + ), + displayName: readString( + record, + ["displayName", "DisplayName"], + `${label}.displayName` + ), + status: readString(record, ["status", "Status"], `${label}.status`), + currentStageIndex: readNumber( + record, + ["currentStageIndex", "CurrentStageIndex"], + `${label}.currentStageIndex` + ), + stages: expectArray( + record.stages ?? record.Stages, + `${label}.stages`, + decodeServiceRolloutStageSnapshot + ), + baselineTargets: expectArray( + record.baselineTargets ?? record.BaselineTargets, + `${label}.baselineTargets`, + decodeServiceServingTargetSnapshot + ), + failureReason: readString( + record, + ["failureReason", "FailureReason"], + `${label}.failureReason` + ), + startedAt: readNullableString( + record, + ["startedAt", "StartedAt"], + `${label}.startedAt` + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +function decodeServiceTrafficTargetSnapshot( + value: unknown, + label = "ServiceTrafficTargetSnapshot" +): ServiceTrafficTargetSnapshot { + const record = expectRecord(value, label); + return { + deploymentId: readString( + record, + ["deploymentId", "DeploymentId"], + `${label}.deploymentId` + ), + revisionId: readString( + record, + ["revisionId", "RevisionId"], + `${label}.revisionId` + ), + primaryActorId: readString( + record, + ["primaryActorId", "PrimaryActorId"], + `${label}.primaryActorId` + ), + allocationWeight: readNumber( + record, + ["allocationWeight", "AllocationWeight"], + `${label}.allocationWeight` + ), + servingState: readString( + record, + ["servingState", "ServingState"], + `${label}.servingState` + ), + }; +} + +function decodeServiceTrafficEndpointSnapshot( + value: unknown, + label = "ServiceTrafficEndpointSnapshot" +): ServiceTrafficEndpointSnapshot { + const record = expectRecord(value, label); + return { + endpointId: readString( + record, + ["endpointId", "EndpointId"], + `${label}.endpointId` + ), + targets: expectArray( + record.targets ?? record.Targets, + `${label}.targets`, + decodeServiceTrafficTargetSnapshot + ), + }; +} + +function decodeServiceTrafficViewSnapshot( + value: unknown, + label = "ServiceTrafficViewSnapshot" +): ServiceTrafficViewSnapshot { + const record = expectRecord(value, label); + return { + serviceKey: readString( + record, + ["serviceKey", "ServiceKey"], + `${label}.serviceKey` + ), + generation: readNumber( + record, + ["generation", "Generation"], + `${label}.generation` + ), + activeRolloutId: readString( + record, + ["activeRolloutId", "ActiveRolloutId"], + `${label}.activeRolloutId` + ), + endpoints: expectArray( + record.endpoints ?? record.Endpoints, + `${label}.endpoints`, + decodeServiceTrafficEndpointSnapshot + ), + updatedAt: readString( + record, + ["updatedAt", "UpdatedAt"], + `${label}.updatedAt` + ), + }; +} + +const decodeServiceCatalogSnapshots: Decoder = ( + value +) => + expectArray(value, "ServiceCatalogSnapshot[]", decodeServiceCatalogSnapshot); + +function buildQuery(query: ServiceIdentityQuery): string { + return withQuery("", { + tenantId: query.tenantId?.trim(), + appId: query.appId?.trim(), + namespace: query.namespace?.trim(), + take: query.take, + }); +} + +export const servicesApi = { + listServices(query: ServiceIdentityQuery): Promise { + return requestJson( + `/api/services${buildQuery(query)}`, + decodeServiceCatalogSnapshots + ); + }, + + getService( + serviceId: string, + query: ServiceIdentityQuery + ): Promise { + return requestJson( + withQuery(`/api/services/${encodeURIComponent(serviceId)}`, { + tenantId: query.tenantId?.trim(), + appId: query.appId?.trim(), + namespace: query.namespace?.trim(), + }), + (value) => + value === null + ? null + : decodeServiceCatalogSnapshot(value, "ServiceCatalogSnapshot") + ); + }, + + getRevisions( + serviceId: string, + query: ServiceIdentityQuery + ): Promise { + return requestJson( + withQuery(`/api/services/${encodeURIComponent(serviceId)}/revisions`, { + tenantId: query.tenantId?.trim(), + appId: query.appId?.trim(), + namespace: query.namespace?.trim(), + }), + (value) => + value === null + ? null + : decodeServiceRevisionCatalogSnapshot( + value, + "ServiceRevisionCatalogSnapshot" + ) + ); + }, + + getDeployments( + serviceId: string, + query: ServiceIdentityQuery + ): Promise { + return requestJson( + withQuery(`/api/services/${encodeURIComponent(serviceId)}/deployments`, { + tenantId: query.tenantId?.trim(), + appId: query.appId?.trim(), + namespace: query.namespace?.trim(), + }), + (value) => + value === null + ? null + : decodeServiceDeploymentCatalogSnapshot( + value, + "ServiceDeploymentCatalogSnapshot" + ) + ); + }, + + getServingSet( + serviceId: string, + query: ServiceIdentityQuery + ): Promise { + return requestJson( + withQuery(`/api/services/${encodeURIComponent(serviceId)}/serving`, { + tenantId: query.tenantId?.trim(), + appId: query.appId?.trim(), + namespace: query.namespace?.trim(), + }), + (value) => + value === null + ? null + : decodeServiceServingSetSnapshot(value, "ServiceServingSetSnapshot") + ); + }, + + getRollout( + serviceId: string, + query: ServiceIdentityQuery + ): Promise { + return requestJson( + withQuery(`/api/services/${encodeURIComponent(serviceId)}/rollouts`, { + tenantId: query.tenantId?.trim(), + appId: query.appId?.trim(), + namespace: query.namespace?.trim(), + }), + (value) => + value === null + ? null + : decodeServiceRolloutSnapshot(value, "ServiceRolloutSnapshot") + ); + }, + + getTraffic( + serviceId: string, + query: ServiceIdentityQuery + ): Promise { + return requestJson( + withQuery(`/api/services/${encodeURIComponent(serviceId)}/traffic`, { + tenantId: query.tenantId?.trim(), + appId: query.appId?.trim(), + namespace: query.namespace?.trim(), + }), + (value) => + value === null + ? null + : decodeServiceTrafficViewSnapshot( + value, + "ServiceTrafficViewSnapshot" + ) + ); + }, +}; diff --git a/apps/aevatar-console-web/src/shared/graphs/buildGraphElements.ts b/apps/aevatar-console-web/src/shared/graphs/buildGraphElements.ts index ed5b63e3..69eb43bb 100644 --- a/apps/aevatar-console-web/src/shared/graphs/buildGraphElements.ts +++ b/apps/aevatar-console-web/src/shared/graphs/buildGraphElements.ts @@ -1,13 +1,17 @@ -import type { Edge, Node } from '@xyflow/react'; +import type { Edge, Node } from "@xyflow/react"; import type { WorkflowActorGraphEdge, WorkflowActorGraphNode, +} from "@/shared/models/runtime/actors"; +import type { WorkflowAuthoringDefinition, WorkflowAuthoringEdge, - WorkflowCatalogEdge, +} from "@/shared/models/runtime/authoring"; +import type { WorkflowCatalogDefinition, + WorkflowCatalogEdge, WorkflowCatalogItemDetail, -} from '../api/models'; +} from "@/shared/models/runtime/catalog"; type WorkflowGraphDetail = { definition: WorkflowCatalogDefinition | WorkflowAuthoringDefinition; @@ -28,7 +32,7 @@ type LayoutNode = { function buildLevels( rootId: string, - edges: Array<{ from: string; to: string }>, + edges: Array<{ from: string; to: string }> ): Map { const outgoing = new Map(); for (const edge of edges) { @@ -59,7 +63,7 @@ function buildLevels( function layoutNodes( nodes: LayoutNode[], edges: Array<{ from: string; to: string }>, - rootId: string, + rootId: string ): Node[] { const levels = buildLevels(rootId, edges); const groups = new Map(); @@ -86,7 +90,7 @@ function layoutNodes( label: node.label, subtitle: node.subtitle, }, - type: 'default', + type: "default", }; }); } @@ -94,11 +98,12 @@ function layoutNodes( export function buildActorGraphElements( nodes: WorkflowActorGraphNode[], edges: WorkflowActorGraphEdge[], - rootId: string, + rootId: string ): { nodes: Node[]; edges: Edge[] } { const mappedNodes = nodes.map((node) => ({ id: node.nodeId, - label: node.properties.stepId || node.properties.workflowName || node.nodeId, + label: + node.properties.stepId || node.properties.workflowName || node.nodeId, subtitle: node.nodeType, })); @@ -109,14 +114,14 @@ export function buildActorGraphElements( from: edge.fromNodeId, to: edge.toNodeId, })), - rootId, + rootId ), edges: edges.map((edge) => ({ id: edge.edgeId, source: edge.fromNodeId, target: edge.toNodeId, label: edge.edgeType, - animated: edge.edgeType === 'OWNS', + animated: edge.edgeType === "OWNS", })), }; } @@ -131,7 +136,7 @@ function buildWorkflowEdges(detail: WorkflowGraphDetail) { return detail.definition.steps.flatMap((step) => { const derived: WorkflowGraphEdgeLike[] = []; if (step.next) { - derived.push({ from: step.id, to: step.next, label: 'next' }); + derived.push({ from: step.id, to: step.next, label: "next" }); } for (const [branch, target] of Object.entries(step.branches ?? {})) { @@ -141,7 +146,11 @@ function buildWorkflowEdges(detail: WorkflowGraphDetail) { } for (const child of step.children ?? []) { - derived.push({ from: step.id, to: child.id, label: child.type || 'child' }); + derived.push({ + from: step.id, + to: child.id, + label: child.type || "child", + }); } return derived; @@ -149,12 +158,12 @@ function buildWorkflowEdges(detail: WorkflowGraphDetail) { } export function buildWorkflowGraphElements( - detail: WorkflowCatalogItemDetail | WorkflowGraphDetail, + detail: WorkflowCatalogItemDetail | WorkflowGraphDetail ): { nodes: Node[]; edges: Edge[] } { const roleNodes: LayoutNode[] = detail.definition.roles.map((role) => ({ id: `role:${role.id}`, label: role.name || role.id, - subtitle: 'Role', + subtitle: "Role", })); const stepNodes: LayoutNode[] = detail.definition.steps.map((step) => ({ @@ -169,19 +178,23 @@ export function buildWorkflowGraphElements( .map((step) => ({ from: `role:${step.targetRole}`, to: step.id, - label: 'targets', + label: "targets", })); const allEdges = [...roleEdges, ...workflowEdges]; return { - nodes: layoutNodes([...roleNodes, ...stepNodes], allEdges, roleNodes[0]?.id ?? stepNodes[0]?.id ?? 'root'), + nodes: layoutNodes( + [...roleNodes, ...stepNodes], + allEdges, + roleNodes[0]?.id ?? stepNodes[0]?.id ?? "root" + ), edges: allEdges.map((edge, index) => ({ id: `${edge.from}-${edge.to}-${index}`, source: edge.from, target: edge.to, label: edge.label, - animated: edge.label === 'targets', + animated: edge.label === "targets", })), }; } diff --git a/apps/aevatar-console-web/src/shared/models/governance.ts b/apps/aevatar-console-web/src/shared/models/governance.ts new file mode 100644 index 00000000..41d3fe4f --- /dev/null +++ b/apps/aevatar-console-web/src/shared/models/governance.ts @@ -0,0 +1,73 @@ +import type { ServiceIdentity } from "./services"; + +export interface BoundServiceReference { + identity: ServiceIdentity; + endpointId: string; +} + +export interface BoundConnectorReference { + connectorType: string; + connectorId: string; +} + +export interface BoundSecretReference { + secretName: string; +} + +export interface ServiceBindingSnapshot { + bindingId: string; + displayName: string; + bindingKind: string; + policyIds: string[]; + retired: boolean; + serviceRef: BoundServiceReference | null; + connectorRef: BoundConnectorReference | null; + secretRef: BoundSecretReference | null; +} + +export interface ServiceBindingCatalogSnapshot { + serviceKey: string; + bindings: ServiceBindingSnapshot[]; + updatedAt: string; +} + +export interface ServicePolicySnapshot { + policyId: string; + displayName: string; + activationRequiredBindingIds: string[]; + invokeAllowedCallerServiceKeys: string[]; + invokeRequiresActiveDeployment: boolean; + retired: boolean; +} + +export interface ServicePolicyCatalogSnapshot { + serviceKey: string; + policies: ServicePolicySnapshot[]; + updatedAt: string; +} + +export interface ServiceEndpointExposureSnapshot { + endpointId: string; + displayName: string; + kind: string; + requestTypeUrl: string; + responseTypeUrl: string; + description: string; + exposureKind: string; + policyIds: string[]; +} + +export interface ServiceEndpointCatalogSnapshot { + serviceKey: string; + endpoints: ServiceEndpointExposureSnapshot[]; + updatedAt: string; +} + +export interface ActivationCapabilityView { + identity: ServiceIdentity; + revisionId: string; + bindings: ServiceBindingSnapshot[]; + endpoints: ServiceEndpointExposureSnapshot[]; + policies: ServicePolicySnapshot[]; + missingPolicyIds: string[]; +} diff --git a/apps/aevatar-console-web/src/shared/models/platform/configuration.ts b/apps/aevatar-console-web/src/shared/models/platform/configuration.ts new file mode 100644 index 00000000..ad07a762 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/models/platform/configuration.ts @@ -0,0 +1,176 @@ +export interface ConfigurationPathInfo { + root: string; + secretsJson: string; + configJson: string; + connectorsJson: string; + mcpJson: string; + workflowsHome: string; + workflowsRepo: string; + homeEnvValue: string | null; + secretsPathEnvValue: string | null; +} + +export interface ConfigurationPathStatus { + path: string; + exists: boolean; + readable: boolean; + writable: boolean; + sizeBytes: number | null; + error: string | null; +} + +export interface ConfigurationDoctorReport { + paths: ConfigurationPathInfo; + secrets: ConfigurationPathStatus; + config: ConfigurationPathStatus; + connectors: ConfigurationPathStatus; + mcp: ConfigurationPathStatus; + workflowsHome: ConfigurationPathStatus; + workflowsRepo: ConfigurationPathStatus; +} + +export interface ConfigurationSourceStatus { + mode: string; + mongoConfigured: boolean; + fileConfigured: boolean; + localRuntimeAccess: boolean; + paths: ConfigurationPathInfo; + doctor: ConfigurationDoctorReport; +} + +export interface ConfigurationWorkflowFile { + filename: string; + source: string; + path: string; + sizeBytes: number; + lastModified: string; +} + +export interface ConfigurationWorkflowFileDetail + extends ConfigurationWorkflowFile { + content: string; +} + +export interface ConfigurationRawDocument { + json: string; + keyCount: number; + exists?: boolean; + path?: string; +} + +export interface ConfigurationCollectionRawDocument { + json: string; + count: number; + exists?: boolean; + path?: string; +} + +export interface ConfigurationValidationResult { + valid: boolean; + message: string; + count: number; +} + +export interface ConfigurationMcpServer { + name: string; + command: string; + args: string[]; + env: Record; + timeoutMs: number; +} + +export interface ConfigurationLlmApiKeyStatus { + providerName: string; + configured: boolean; + masked: string; + value?: string; +} + +export interface ConfigurationSecretValueStatus { + configured: boolean; + masked: string; + keyPath: string; + value?: string; +} + +export interface ConfigurationEmbeddingsStatus { + enabled: boolean | null; + providerType: string; + endpoint: string; + model: string; + configured: boolean; + masked: string; +} + +export interface ConfigurationWebSearchStatus { + enabled: boolean | null; + effectiveEnabled: boolean; + provider: string; + endpoint: string; + timeoutMs: number | null; + searchDepth: string; + configured: boolean; + masked: string; + available: boolean; +} + +export interface ConfigurationSkillsMpStatus { + configured: boolean; + masked: string; + keyPath: string; + baseUrl: string; +} + +export interface ConfigurationSecp256k1PrivateKeyStatus { + configured: boolean; + masked: string; + keyPath: string; + backupsPrefix: string; + backupCount: number; +} + +export interface ConfigurationSecp256k1PublicKeyStatus { + configured: boolean; + hex: string; +} + +export interface ConfigurationSecp256k1Status { + configured: boolean; + privateKey: ConfigurationSecp256k1PrivateKeyStatus; + publicKey: ConfigurationSecp256k1PublicKeyStatus; +} + +export interface ConfigurationSecp256k1GenerateResult { + backedUp: boolean; + publicKeyHex: string; + status: ConfigurationSecp256k1Status; +} + +export interface ConfigurationLlmProbeResult { + ok: boolean; + providerName: string; + kind: string; + endpoint: string; + latencyMs?: number; + error?: string; + modelsCount?: number; + sampleModels?: string[]; + models?: string[]; +} + +export interface ConfigurationLlmProviderType { + id: string; + displayName: string; + category: string; + description: string; + recommended: boolean; + configuredInstancesCount: number; +} + +export interface ConfigurationLlmInstance { + name: string; + providerType: string; + providerDisplayName: string; + model: string; + endpoint: string; +} diff --git a/apps/aevatar-console-web/src/shared/models/runtime/actors.ts b/apps/aevatar-console-web/src/shared/models/runtime/actors.ts new file mode 100644 index 00000000..b41f3da2 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/models/runtime/actors.ts @@ -0,0 +1,53 @@ +export interface WorkflowActorSnapshot { + actorId: string; + workflowName: string; + lastCommandId: string; + stateVersion: number; + lastEventId: string; + lastUpdatedAt: string; + lastSuccess: boolean | null; + lastOutput: string; + lastError: string; + totalSteps: number; + requestedSteps: number; + completedSteps: number; + roleReplyCount: number; +} + +export interface WorkflowActorTimelineItem { + timestamp: string; + stage: string; + message: string; + agentId: string; + stepId: string; + stepType: string; + eventType: string; + data: Record; +} + +export interface WorkflowActorGraphNode { + nodeId: string; + nodeType: string; + updatedAt: string; + properties: Record; +} + +export interface WorkflowActorGraphEdge { + edgeId: string; + fromNodeId: string; + toNodeId: string; + edgeType: string; + updatedAt: string; + properties: Record; +} + +export interface WorkflowActorGraphSubgraph { + rootNodeId: string; + nodes: WorkflowActorGraphNode[]; + edges: WorkflowActorGraphEdge[]; +} + +export interface WorkflowActorGraphEnrichedSnapshot { + snapshot: WorkflowActorSnapshot; + subgraph: WorkflowActorGraphSubgraph; +} diff --git a/apps/aevatar-console-web/src/shared/models/runtime/authoring.ts b/apps/aevatar-console-web/src/shared/models/runtime/authoring.ts new file mode 100644 index 00000000..0a08e0ad --- /dev/null +++ b/apps/aevatar-console-web/src/shared/models/runtime/authoring.ts @@ -0,0 +1,73 @@ +export interface WorkflowAuthoringRetryPolicy { + maxAttempts: number; + backoff: string; + delayMs: number; +} + +export interface WorkflowAuthoringErrorPolicy { + strategy: string; + fallbackStep: string | null; + defaultOutput: string | null; +} + +export interface WorkflowAuthoringRole { + id: string; + name: string; + systemPrompt: string; + provider: string | null; + model: string | null; + temperature: number | null; + maxTokens: number | null; + maxToolRounds: number | null; + maxHistoryMessages: number | null; + streamBufferCapacity: number | null; + eventModules: string[]; + eventRoutes: string; + connectors: string[]; +} + +export interface WorkflowAuthoringStep { + id: string; + type: string; + targetRole: string; + parameters: Record; + next: string | null; + branches: Record; + children: WorkflowAuthoringStep[]; + retry: WorkflowAuthoringRetryPolicy | null; + onError: WorkflowAuthoringErrorPolicy | null; + timeoutMs: number | null; +} + +export interface WorkflowAuthoringDefinition { + name: string; + description: string; + closedWorldMode: boolean; + roles: WorkflowAuthoringRole[]; + steps: WorkflowAuthoringStep[]; +} + +export interface WorkflowAuthoringEdge { + from: string; + to: string; + label: string; +} + +export interface PlaygroundWorkflowParseResult { + valid: boolean; + error: string | null; + errors: string[]; + definition: WorkflowAuthoringDefinition | null; + edges: WorkflowAuthoringEdge[]; +} + +export interface PlaygroundWorkflowSaveResult { + saved: boolean; + filename: string; + savedPath: string; + workflowName: string; + overwritten: boolean; + savedSource: string; + effectiveSource: string; + effectivePath: string; +} diff --git a/apps/aevatar-console-web/src/shared/models/runtime/catalog.ts b/apps/aevatar-console-web/src/shared/models/runtime/catalog.ts new file mode 100644 index 00000000..c0a4c7a3 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/models/runtime/catalog.ts @@ -0,0 +1,67 @@ +export interface WorkflowCatalogItem { + name: string; + description: string; + category: string; + group: string; + groupLabel: string; + sortOrder: number; + source: string; + sourceLabel: string; + showInLibrary: boolean; + isPrimitiveExample: boolean; + requiresLlmProvider: boolean; + primitives: string[]; +} + +export interface WorkflowCatalogRole { + id: string; + name: string; + systemPrompt: string; + provider: string; + model: string; + temperature: number | null; + maxTokens: number | null; + maxToolRounds: number | null; + maxHistoryMessages: number | null; + streamBufferCapacity: number | null; + eventModules: string[]; + eventRoutes: string; + connectors: string[]; +} + +export interface WorkflowCatalogChildStep { + id: string; + type: string; + targetRole: string; +} + +export interface WorkflowCatalogStep { + id: string; + type: string; + targetRole: string; + parameters: Record; + next: string; + branches: Record; + children: WorkflowCatalogChildStep[]; +} + +export interface WorkflowCatalogEdge { + from: string; + to: string; + label: string; +} + +export interface WorkflowCatalogDefinition { + name: string; + description: string; + closedWorldMode: boolean; + roles: WorkflowCatalogRole[]; + steps: WorkflowCatalogStep[]; +} + +export interface WorkflowCatalogItemDetail { + catalog: WorkflowCatalogItem; + yaml: string; + definition: WorkflowCatalogDefinition; + edges: WorkflowCatalogEdge[]; +} diff --git a/apps/aevatar-console-web/src/shared/models/runtime/query.ts b/apps/aevatar-console-web/src/shared/models/runtime/query.ts new file mode 100644 index 00000000..5fbb4f9b --- /dev/null +++ b/apps/aevatar-console-web/src/shared/models/runtime/query.ts @@ -0,0 +1,86 @@ +export interface WorkflowAgentSummary { + id: string; + type: string; + description: string; +} + +export interface WorkflowCapabilityParameter { + name: string; + type: string; + required: boolean; + description: string; + default: string; + enum: string[]; +} + +export interface WorkflowPrimitiveCapability { + name: string; + aliases: string[]; + category: string; + description: string; + closedWorldBlocked: boolean; + runtimeModule: string; + parameters: WorkflowCapabilityParameter[]; +} + +export interface WorkflowConnectorCapability { + name: string; + type: string; + enabled: boolean; + timeoutMs: number; + retry: number; + allowedInputKeys: string[]; + allowedOperations: string[]; + fixedArguments: string[]; +} + +export interface WorkflowCapabilityWorkflowStep { + id: string; + type: string; + next: string; +} + +export interface WorkflowCapabilityWorkflow { + name: string; + description: string; + source: string; + closedWorldMode: boolean; + requiresLlmProvider: boolean; + primitives: string[]; + requiredConnectors: string[]; + workflowCalls: string[]; + steps: WorkflowCapabilityWorkflowStep[]; +} + +export interface WorkflowCapabilities { + schemaVersion: string; + generatedAtUtc: string; + primitives: WorkflowPrimitiveCapability[]; + connectors: WorkflowConnectorCapability[]; + workflows: WorkflowCapabilityWorkflow[]; +} + +export interface WorkflowPrimitiveParameterDescriptor { + name: string; + type: string; + required: boolean; + description: string; + default: string; + enumValues: string[]; +} + +export interface WorkflowPrimitiveDescriptor { + name: string; + aliases: string[]; + category: string; + description: string; + parameters: WorkflowPrimitiveParameterDescriptor[]; + exampleWorkflows: string[]; +} + +export interface WorkflowLlmStatus { + available: boolean; + provider: string | null; + model: string | null; + providers: string[]; +} diff --git a/apps/aevatar-console-web/src/shared/models/scopes.ts b/apps/aevatar-console-web/src/shared/models/scopes.ts new file mode 100644 index 00000000..753dd2f5 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/models/scopes.ts @@ -0,0 +1,62 @@ +export interface ScopeWorkflowSummary { + scopeId: string; + workflowId: string; + displayName: string; + serviceKey: string; + workflowName: string; + actorId: string; + activeRevisionId: string; + deploymentId: string; + deploymentStatus: string; + updatedAt: string; +} + +export interface ScopeWorkflowSource { + workflowYaml: string; + definitionActorId: string; + inlineWorkflowYamls: Record | null; +} + +export interface ScopeWorkflowDetail { + available: boolean; + scopeId: string; + workflow: ScopeWorkflowSummary | null; + source: ScopeWorkflowSource | null; +} + +export interface ScopeScriptSummary { + scopeId: string; + scriptId: string; + catalogActorId: string; + definitionActorId: string; + activeRevision: string; + activeSourceHash: string; + updatedAt: string; +} + +export interface ScopeScriptSource { + sourceText: string; + definitionActorId: string; + revision: string; + sourceHash: string; +} + +export interface ScopeScriptDetail { + available: boolean; + scopeId: string; + script: ScopeScriptSummary | null; + source: ScopeScriptSource | null; +} + +export interface ScopeScriptCatalog { + scriptId: string; + activeRevision: string; + activeDefinitionActorId: string; + activeSourceHash: string; + previousRevision: string; + revisionHistory: string[]; + lastProposalId: string; + catalogActorId: string; + scopeId: string; + updatedAt: string; +} diff --git a/apps/aevatar-console-web/src/shared/models/services.ts b/apps/aevatar-console-web/src/shared/models/services.ts new file mode 100644 index 00000000..c00dd9a9 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/models/services.ts @@ -0,0 +1,130 @@ +export interface ServiceIdentityQuery { + tenantId?: string; + appId?: string; + namespace?: string; + take?: number; +} + +export interface ServiceIdentity { + tenantId: string; + appId: string; + namespace: string; + serviceId: string; +} + +export interface ServiceEndpointSnapshot { + endpointId: string; + displayName: string; + kind: string; + requestTypeUrl: string; + responseTypeUrl: string; + description: string; +} + +export interface ServiceCatalogSnapshot { + serviceKey: string; + tenantId: string; + appId: string; + namespace: string; + serviceId: string; + displayName: string; + defaultServingRevisionId: string; + activeServingRevisionId: string; + deploymentId: string; + primaryActorId: string; + deploymentStatus: string; + endpoints: ServiceEndpointSnapshot[]; + policyIds: string[]; + updatedAt: string; +} + +export interface ServiceRevisionSnapshot { + revisionId: string; + implementationKind: string; + status: string; + artifactHash: string; + failureReason: string; + endpoints: ServiceEndpointSnapshot[]; + createdAt: string | null; + preparedAt: string | null; + publishedAt: string | null; + retiredAt: string | null; +} + +export interface ServiceRevisionCatalogSnapshot { + serviceKey: string; + revisions: ServiceRevisionSnapshot[]; + updatedAt: string; +} + +export interface ServiceDeploymentSnapshot { + deploymentId: string; + revisionId: string; + primaryActorId: string; + status: string; + activatedAt: string | null; + updatedAt: string; +} + +export interface ServiceDeploymentCatalogSnapshot { + serviceKey: string; + deployments: ServiceDeploymentSnapshot[]; + updatedAt: string; +} + +export interface ServiceServingTargetSnapshot { + deploymentId: string; + revisionId: string; + primaryActorId: string; + allocationWeight: number; + servingState: string; + enabledEndpointIds: string[]; +} + +export interface ServiceServingSetSnapshot { + serviceKey: string; + generation: number; + activeRolloutId: string; + targets: ServiceServingTargetSnapshot[]; + updatedAt: string; +} + +export interface ServiceRolloutStageSnapshot { + stageId: string; + stageIndex: number; + targets: ServiceServingTargetSnapshot[]; +} + +export interface ServiceRolloutSnapshot { + serviceKey: string; + rolloutId: string; + displayName: string; + status: string; + currentStageIndex: number; + stages: ServiceRolloutStageSnapshot[]; + baselineTargets: ServiceServingTargetSnapshot[]; + failureReason: string; + startedAt: string | null; + updatedAt: string; +} + +export interface ServiceTrafficTargetSnapshot { + deploymentId: string; + revisionId: string; + primaryActorId: string; + allocationWeight: number; + servingState: string; +} + +export interface ServiceTrafficEndpointSnapshot { + endpointId: string; + targets: ServiceTrafficTargetSnapshot[]; +} + +export interface ServiceTrafficViewSnapshot { + serviceKey: string; + generation: number; + activeRolloutId: string; + endpoints: ServiceTrafficEndpointSnapshot[]; + updatedAt: string; +} diff --git a/apps/aevatar-console-web/src/shared/playground/draftStatus.test.ts b/apps/aevatar-console-web/src/shared/playground/draftStatus.test.ts deleted file mode 100644 index 604db7d3..00000000 --- a/apps/aevatar-console-web/src/shared/playground/draftStatus.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { PlaygroundDraft } from './playgroundDraft'; -import { getPlaygroundDraftStatus, summarizeYamlLineDiff } from './draftStatus'; - -function createDraft(overrides?: Partial): PlaygroundDraft { - return { - yaml: 'name: sample\nsteps: []\n', - prompt: 'run this', - sourceWorkflow: 'direct_review', - updatedAt: '2026-03-12T00:00:00Z', - ...overrides, - }; -} - -describe('getPlaygroundDraftStatus', () => { - it('reports when no draft exists', () => { - expect( - getPlaygroundDraftStatus( - createDraft({ - yaml: '', - prompt: '', - sourceWorkflow: '', - updatedAt: '', - }), - ), - ).toMatchObject({ - hasDraft: false, - label: 'No draft saved', - summary: 'No draft saved', - alertType: 'info', - }); - }); - - it('reports when the current draft still matches the selected workflow', () => { - expect( - getPlaygroundDraftStatus(createDraft(), { - referenceWorkflow: 'direct_review', - referenceYaml: 'name: sample\nsteps: []\n', - }), - ).toMatchObject({ - hasDraft: true, - label: 'Aligned with template', - summary: 'Matches direct_review', - alertType: 'success', - differsFromReference: false, - }); - }); - - it('reports local edits when the draft diverges from the selected workflow', () => { - expect( - getPlaygroundDraftStatus(createDraft(), { - referenceWorkflow: 'direct_review', - referenceYaml: 'name: sample\nsteps:\n - id: start\n', - }), - ).toMatchObject({ - hasDraft: true, - label: 'Modified from template', - summary: 'Edited from direct_review', - alertType: 'warning', - differsFromReference: true, - }); - }); - - it('reports when the draft is based on another workflow', () => { - expect( - getPlaygroundDraftStatus(createDraft(), { - referenceWorkflow: 'human_input_manual_triage', - referenceYaml: 'name: human_input_manual_triage\nsteps: []\n', - }), - ).toMatchObject({ - hasDraft: true, - label: 'Linked to another workflow', - summary: 'Based on direct_review', - alertType: 'info', - matchesReferenceWorkflow: false, - }); - }); - - it('summarizes line-level draft differences against a template', () => { - expect( - summarizeYamlLineDiff( - 'name: draft\nsteps:\n - id: start\n - id: finish\n', - 'name: draft\nsteps:\n - id: start\n', - ), - ).toEqual({ - draftLineCount: 4, - referenceLineCount: 3, - changedLineCount: 1, - addedLineCount: 1, - removedLineCount: 0, - }); - }); -}); diff --git a/apps/aevatar-console-web/src/shared/playground/draftStatus.ts b/apps/aevatar-console-web/src/shared/playground/draftStatus.ts deleted file mode 100644 index 5b6ee99d..00000000 --- a/apps/aevatar-console-web/src/shared/playground/draftStatus.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { PlaygroundDraft } from './playgroundDraft'; - -export type PlaygroundDraftStatusTone = 'default' | 'processing' | 'success' | 'warning'; - -export type PlaygroundDraftAlertType = 'info' | 'success' | 'warning'; - -export interface PlaygroundDraftStatus { - hasDraft: boolean; - lineCount: number; - label: string; - summary: string; - detail: string; - tagColor: PlaygroundDraftStatusTone; - alertType: PlaygroundDraftAlertType; - sourceWorkflow: string; - updatedAt: string; - matchesReferenceWorkflow: boolean; - differsFromReference: boolean | null; -} - -export interface YamlLineDiffSummary { - draftLineCount: number; - referenceLineCount: number; - changedLineCount: number; - addedLineCount: number; - removedLineCount: number; -} - -export function countYamlLines(value: string): number { - if (!value.trim()) { - return 0; - } - - return value.split(/\r?\n/).length; -} - -function normalizeYaml(value?: string | null): string { - return value?.trim() ?? ''; -} - -export function summarizeYamlLineDiff( - draftYaml: string, - referenceYaml: string, -): YamlLineDiffSummary { - const normalizedDraftYaml = normalizeYaml(draftYaml); - const normalizedReferenceYaml = normalizeYaml(referenceYaml); - const draftLines = normalizedDraftYaml ? normalizedDraftYaml.split(/\r?\n/) : []; - const referenceLines = normalizedReferenceYaml - ? normalizedReferenceYaml.split(/\r?\n/) - : []; - const sharedLength = Math.min(draftLines.length, referenceLines.length); - let changedLineCount = 0; - - for (let index = 0; index < sharedLength; index += 1) { - if (draftLines[index] !== referenceLines[index]) { - changedLineCount += 1; - } - } - - return { - draftLineCount: draftLines.length, - referenceLineCount: referenceLines.length, - changedLineCount: changedLineCount + Math.abs(draftLines.length - referenceLines.length), - addedLineCount: Math.max(draftLines.length - referenceLines.length, 0), - removedLineCount: Math.max(referenceLines.length - draftLines.length, 0), - }; -} - -export function getPlaygroundDraftStatus( - draft: PlaygroundDraft, - options?: { - referenceWorkflow?: string; - referenceYaml?: string; - }, -): PlaygroundDraftStatus { - const normalizedDraftYaml = normalizeYaml(draft.yaml); - const sourceWorkflow = draft.sourceWorkflow.trim(); - const referenceWorkflow = options?.referenceWorkflow?.trim() ?? ''; - const hasDraft = normalizedDraftYaml.length > 0; - const matchesReferenceWorkflow = Boolean( - sourceWorkflow && referenceWorkflow && sourceWorkflow === referenceWorkflow, - ); - const differsFromReference = - hasDraft && matchesReferenceWorkflow && typeof options?.referenceYaml === 'string' - ? normalizedDraftYaml !== normalizeYaml(options.referenceYaml) - : null; - - if (!hasDraft) { - return { - hasDraft: false, - lineCount: 0, - label: 'No draft saved', - summary: 'No draft saved', - detail: referenceWorkflow - ? `Import ${referenceWorkflow} into Playground to start editing a local draft.` - : 'Import a workflow into Playground to start editing a local draft.', - tagColor: 'default', - alertType: 'info', - sourceWorkflow, - updatedAt: draft.updatedAt, - matchesReferenceWorkflow, - differsFromReference: null, - }; - } - - if (matchesReferenceWorkflow) { - if (differsFromReference === false) { - return { - hasDraft: true, - lineCount: countYamlLines(draft.yaml), - label: 'Aligned with template', - summary: `Matches ${referenceWorkflow}`, - detail: `The current draft still matches ${referenceWorkflow} and can be resumed in Playground without re-importing.`, - tagColor: 'success', - alertType: 'success', - sourceWorkflow, - updatedAt: draft.updatedAt, - matchesReferenceWorkflow, - differsFromReference, - }; - } - - if (differsFromReference === true) { - return { - hasDraft: true, - lineCount: countYamlLines(draft.yaml), - label: 'Modified from template', - summary: `Edited from ${referenceWorkflow}`, - detail: `The current draft started from ${referenceWorkflow} and now contains local edits that differ from the library YAML.`, - tagColor: 'warning', - alertType: 'warning', - sourceWorkflow, - updatedAt: draft.updatedAt, - matchesReferenceWorkflow, - differsFromReference, - }; - } - - return { - hasDraft: true, - lineCount: countYamlLines(draft.yaml), - label: 'Linked to template', - summary: `Based on ${referenceWorkflow}`, - detail: `The current draft is linked to ${referenceWorkflow}; compare or re-import it from Playground when needed.`, - tagColor: 'processing', - alertType: 'info', - sourceWorkflow, - updatedAt: draft.updatedAt, - matchesReferenceWorkflow, - differsFromReference, - }; - } - - if (sourceWorkflow) { - return { - hasDraft: true, - lineCount: countYamlLines(draft.yaml), - label: referenceWorkflow ? 'Linked to another workflow' : 'Workflow-based draft', - summary: `Based on ${sourceWorkflow}`, - detail: referenceWorkflow - ? `The current draft is still based on ${sourceWorkflow}. Import ${referenceWorkflow} into Playground if you want this workflow to replace the local draft.` - : `The current draft is based on ${sourceWorkflow} and can be resumed in Playground.`, - tagColor: 'processing', - alertType: 'info', - sourceWorkflow, - updatedAt: draft.updatedAt, - matchesReferenceWorkflow, - differsFromReference, - }; - } - - return { - hasDraft: true, - lineCount: countYamlLines(draft.yaml), - label: 'Inline draft', - summary: 'Inline local draft', - detail: referenceWorkflow - ? `The current draft is not linked to ${referenceWorkflow}; it only exists in local browser storage.` - : 'The current draft only exists in local browser storage and is not linked to a workflow template.', - tagColor: 'processing', - alertType: 'info', - sourceWorkflow, - updatedAt: draft.updatedAt, - matchesReferenceWorkflow, - differsFromReference, - }; -} diff --git a/apps/aevatar-console-web/src/shared/playground/navigation.test.ts b/apps/aevatar-console-web/src/shared/playground/navigation.test.ts deleted file mode 100644 index aff168fa..00000000 --- a/apps/aevatar-console-web/src/shared/playground/navigation.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { buildPlaygroundRoute, buildYamlBrowserRoute } from './navigation'; - -describe('playground navigation helpers', () => { - it('builds yaml browser routes with optional source and workflow', () => { - expect(buildYamlBrowserRoute()).toBe('/yaml'); - expect(buildYamlBrowserRoute({ workflow: 'direct' })).toBe('/yaml?workflow=direct'); - expect( - buildYamlBrowserRoute({ workflow: 'direct', source: 'playground' }), - ).toBe('/yaml?workflow=direct&source=playground'); - }); - - it('builds playground routes with template import and prompt', () => { - expect(buildPlaygroundRoute()).toBe('/playground'); - expect( - buildPlaygroundRoute({ - template: 'human_input_manual_triage', - importTemplate: true, - prompt: 'Review this flow', - }), - ).toBe( - '/playground?template=human_input_manual_triage&import=1&prompt=Review+this+flow', - ); - }); -}); diff --git a/apps/aevatar-console-web/src/shared/playground/navigation.ts b/apps/aevatar-console-web/src/shared/playground/navigation.ts deleted file mode 100644 index d6777ebb..00000000 --- a/apps/aevatar-console-web/src/shared/playground/navigation.ts +++ /dev/null @@ -1,37 +0,0 @@ -export type YamlBrowserSource = 'workflow' | 'playground'; - -export function buildYamlBrowserRoute(options?: { - workflow?: string; - source?: YamlBrowserSource; -}): string { - const params = new URLSearchParams(); - if (options?.workflow?.trim()) { - params.set('workflow', options.workflow.trim()); - } - if (options?.source === 'playground') { - params.set('source', 'playground'); - } - - const query = params.toString(); - return query ? `/yaml?${query}` : '/yaml'; -} - -export function buildPlaygroundRoute(options?: { - template?: string; - importTemplate?: boolean; - prompt?: string; -}): string { - const params = new URLSearchParams(); - if (options?.template?.trim()) { - params.set('template', options.template.trim()); - } - if (options?.importTemplate) { - params.set('import', '1'); - } - if (options?.prompt?.trim()) { - params.set('prompt', options.prompt.trim()); - } - - const query = params.toString(); - return query ? `/playground?${query}` : '/playground'; -} diff --git a/apps/aevatar-console-web/src/shared/playground/stepSummary.ts b/apps/aevatar-console-web/src/shared/playground/stepSummary.ts index c40833ba..46e3dfb3 100644 --- a/apps/aevatar-console-web/src/shared/playground/stepSummary.ts +++ b/apps/aevatar-console-web/src/shared/playground/stepSummary.ts @@ -1,36 +1,34 @@ -import { parseCustomEvent } from '@aevatar-react-sdk/agui'; +import { parseCustomEvent } from "@aevatar-react-sdk/agui"; import { AGUIEventType, CustomEventName, type AGUIEvent, -} from '@aevatar-react-sdk/types'; +} from "@aevatar-react-sdk/types"; import { parseHumanInputRequestData, parseStepCompletedData, parseStepRequestData, parseWaitingSignalData, -} from '@/shared/agui/customEventData'; -import type { - WorkflowActorTimelineItem, - WorkflowAuthoringStep, - WorkflowCatalogStep, -} from '@/shared/api/models'; +} from "@/shared/agui/customEventData"; +import type { WorkflowActorTimelineItem } from "@/shared/models/runtime/actors"; +import type { WorkflowAuthoringStep } from "@/shared/models/runtime/authoring"; +import type { WorkflowCatalogStep } from "@/shared/models/runtime/catalog"; type PlaygroundReferenceStep = WorkflowCatalogStep | WorkflowAuthoringStep; export type PlaygroundStepStatus = - | 'idle' - | 'running' - | 'waiting' - | 'success' - | 'error'; + | "idle" + | "running" + | "waiting" + | "success" + | "error"; export type PlaygroundStepSummary = { key: string; stepId: string; stepType: string; targetRole: string; - source: 'reference' | 'runtime' | 'merged'; + source: "reference" | "runtime" | "merged"; status: PlaygroundStepStatus; statusLabel: string; checkpointLabel: string; @@ -55,89 +53,90 @@ export type PlaygroundStepMetrics = { type MutableStepSummary = PlaygroundStepSummary; function isApprovalSuspension(suspensionType?: string | null): boolean { - return suspensionType?.toLowerCase().includes('approval') ?? false; + return suspensionType?.toLowerCase().includes("approval") ?? false; } function makeDefaultSummary( stepId: string, - referenceOrder: number, + referenceOrder: number ): MutableStepSummary { return { key: stepId || `runtime-${referenceOrder}`, stepId, - stepType: '', - targetRole: '', - source: 'runtime', - status: 'idle', - statusLabel: 'Idle', - checkpointLabel: '', - lastStage: '', - lastMessage: '', - agentId: '', + stepType: "", + targetRole: "", + source: "runtime", + status: "idle", + statusLabel: "Idle", + checkpointLabel: "", + lastStage: "", + lastMessage: "", + agentId: "", observationCount: 0, - startedAt: '', - updatedAt: '', + startedAt: "", + updatedAt: "", referenceOrder, }; } function normalizeTimelineStatus( - item: WorkflowActorTimelineItem, + item: WorkflowActorTimelineItem ): PlaygroundStepStatus { - const normalized = `${item.stage} ${item.eventType} ${item.message}`.toLowerCase(); + const normalized = + `${item.stage} ${item.eventType} ${item.message}`.toLowerCase(); - if (normalized.includes('error') || normalized.includes('fail')) { - return 'error'; + if (normalized.includes("error") || normalized.includes("fail")) { + return "error"; } if ( - normalized.includes('wait') || - normalized.includes('signal') || - normalized.includes('approval') || - normalized.includes('human') + normalized.includes("wait") || + normalized.includes("signal") || + normalized.includes("approval") || + normalized.includes("human") ) { - return 'waiting'; + return "waiting"; } if ( - normalized.includes('complete') || - normalized.includes('finish') || - normalized.includes('success') + normalized.includes("complete") || + normalized.includes("finish") || + normalized.includes("success") ) { - return 'success'; + return "success"; } if ( - normalized.includes('start') || - normalized.includes('run') || - normalized.includes('request') + normalized.includes("start") || + normalized.includes("run") || + normalized.includes("request") ) { - return 'running'; + return "running"; } - return 'idle'; + return "idle"; } function statusLabel(status: PlaygroundStepStatus): string { switch (status) { - case 'running': - return 'Running'; - case 'waiting': - return 'Waiting'; - case 'success': - return 'Completed'; - case 'error': - return 'Failed'; + case "running": + return "Running"; + case "waiting": + return "Waiting"; + case "success": + return "Completed"; + case "error": + return "Failed"; default: - return 'Idle'; + return "Idle"; } } function mergeStepSource( - source: PlaygroundStepSummary['source'], -): PlaygroundStepSummary['source'] { - if (source === 'reference' || source === 'merged') { - return 'merged'; + source: PlaygroundStepSummary["source"] +): PlaygroundStepSummary["source"] { + if (source === "reference" || source === "merged") { + return "merged"; } - return 'runtime'; + return "runtime"; } function applyObservation( @@ -150,7 +149,7 @@ function applyObservation( agentId?: string; stepType?: string; checkpointLabel?: string; - }, + } ): void { summary.status = input.status; summary.statusLabel = statusLabel(input.status); @@ -182,7 +181,7 @@ function applyObservation( function getOrCreateStep( map: Map, stepId: string, - nextRuntimeOrder: () => number, + nextRuntimeOrder: () => number ): MutableStepSummary { if (stepId) { const existing = map.get(stepId); @@ -215,12 +214,12 @@ export function buildPlaygroundStepSummaries(input: { const summary = makeDefaultSummary(step.id, index); summary.stepType = step.type; summary.targetRole = step.targetRole; - summary.source = 'reference'; + summary.source = "reference"; stepMap.set(step.id, summary); } const orderedTimeline = [...(input.actorTimeline ?? [])].sort((left, right) => - left.timestamp.localeCompare(right.timestamp), + left.timestamp.localeCompare(right.timestamp) ); for (const item of orderedTimeline) { @@ -238,43 +237,53 @@ export function buildPlaygroundStepSummaries(input: { agentId: item.agentId, stepType: item.stepType, checkpointLabel: - normalizeTimelineStatus(item) === 'waiting' - ? item.stepType || 'Checkpoint' + normalizeTimelineStatus(item) === "waiting" + ? item.stepType || "Checkpoint" : summary.checkpointLabel, }); } const orderedEvents = [...(input.events ?? [])].sort( - (left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0), + (left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0) ); for (const event of orderedEvents) { - const updatedAt = event.timestamp ? new Date(event.timestamp).toISOString() : ''; + const updatedAt = event.timestamp + ? new Date(event.timestamp).toISOString() + : ""; if (event.type === AGUIEventType.HUMAN_INPUT_REQUEST) { - const summary = getOrCreateStep(stepMap, event.stepId ?? '', nextRuntimeOrder); + const summary = getOrCreateStep( + stepMap, + event.stepId ?? "", + nextRuntimeOrder + ); summary.source = mergeStepSource(summary.source); applyObservation(summary, { - status: 'waiting', + status: "waiting", updatedAt, stage: event.type, message: event.prompt, stepType: event.suspensionType, checkpointLabel: isApprovalSuspension(event.suspensionType) - ? 'Approval' - : 'Human input', + ? "Approval" + : "Human input", }); continue; } if (event.type === AGUIEventType.HUMAN_INPUT_RESPONSE) { - const summary = getOrCreateStep(stepMap, event.stepId ?? '', nextRuntimeOrder); + const summary = getOrCreateStep( + stepMap, + event.stepId ?? "", + nextRuntimeOrder + ); summary.source = mergeStepSource(summary.source); applyObservation(summary, { - status: 'running', + status: "running", updatedAt, stage: event.type, - message: `Human input submitted for ${event.stepId ?? 'unknown step'}`, + message: `Human input submitted for ${event.stepId ?? "unknown step"}`, }); continue; } @@ -288,15 +297,15 @@ export function buildPlaygroundStepSummaries(input: { const data = parseStepRequestData(custom.data); const summary = getOrCreateStep( stepMap, - data?.stepId ?? '', - nextRuntimeOrder, + data?.stepId ?? "", + nextRuntimeOrder ); summary.source = mergeStepSource(summary.source); applyObservation(summary, { - status: 'running', + status: "running", updatedAt, stage: custom.name, - message: data?.stepType || 'Step requested.', + message: data?.stepType || "Step requested.", stepType: data?.stepType, }); continue; @@ -306,18 +315,18 @@ export function buildPlaygroundStepSummaries(input: { const data = parseStepCompletedData(custom.data); const summary = getOrCreateStep( stepMap, - data?.stepId ?? '', - nextRuntimeOrder, + data?.stepId ?? "", + nextRuntimeOrder ); summary.source = mergeStepSource(summary.source); applyObservation(summary, { - status: data?.success === false ? 'error' : 'success', + status: data?.success === false ? "error" : "success", updatedAt, stage: custom.name, message: data?.success === false - ? 'Step failed.' - : 'Step completed successfully.', + ? "Step failed." + : "Step completed successfully.", }); continue; } @@ -326,17 +335,18 @@ export function buildPlaygroundStepSummaries(input: { const data = parseWaitingSignalData(custom.data); const summary = getOrCreateStep( stepMap, - data?.stepId ?? '', - nextRuntimeOrder, + data?.stepId ?? "", + nextRuntimeOrder ); summary.source = mergeStepSource(summary.source); applyObservation(summary, { - status: 'waiting', + status: "waiting", updatedAt, stage: custom.name, message: - data?.prompt || `Waiting for signal ${data?.signalName ?? 'unknown'}.`, - checkpointLabel: data?.signalName || 'Signal', + data?.prompt || + `Waiting for signal ${data?.signalName ?? "unknown"}.`, + checkpointLabel: data?.signalName || "Signal", }); continue; } @@ -345,19 +355,19 @@ export function buildPlaygroundStepSummaries(input: { const data = parseHumanInputRequestData(custom.data); const summary = getOrCreateStep( stepMap, - data?.stepId ?? '', - nextRuntimeOrder, + data?.stepId ?? "", + nextRuntimeOrder ); summary.source = mergeStepSource(summary.source); applyObservation(summary, { - status: 'waiting', + status: "waiting", updatedAt, stage: custom.name, message: data?.prompt, stepType: data?.suspensionType, checkpointLabel: isApprovalSuspension(data?.suspensionType) - ? 'Approval' - : 'Human input', + ? "Approval" + : "Human input", }); } } @@ -372,14 +382,15 @@ export function buildPlaygroundStepSummaries(input: { } export function summarizePlaygroundSteps( - steps: PlaygroundStepSummary[], + steps: PlaygroundStepSummary[] ): PlaygroundStepMetrics { return { - totalReferenceSteps: steps.filter((item) => item.source !== 'runtime').length, + totalReferenceSteps: steps.filter((item) => item.source !== "runtime") + .length, observedSteps: steps.filter((item) => item.observationCount > 0).length, - runningSteps: steps.filter((item) => item.status === 'running').length, - waitingSteps: steps.filter((item) => item.status === 'waiting').length, - successfulSteps: steps.filter((item) => item.status === 'success').length, - failedSteps: steps.filter((item) => item.status === 'error').length, + runningSteps: steps.filter((item) => item.status === "running").length, + waitingSteps: steps.filter((item) => item.status === "waiting").length, + successfulSteps: steps.filter((item) => item.status === "success").length, + failedSteps: steps.filter((item) => item.status === "error").length, }; } diff --git a/apps/aevatar-console-web/src/shared/studio/api.ts b/apps/aevatar-console-web/src/shared/studio/api.ts index a40f99fd..f5d4048a 100644 --- a/apps/aevatar-console-web/src/shared/studio/api.ts +++ b/apps/aevatar-console-web/src/shared/studio/api.ts @@ -20,28 +20,28 @@ import type { StudioWorkflowFile, StudioWorkflowSummary, StudioWorkspaceSettings, -} from './models'; -import type { WorkflowCatalogItemDetail } from '@/shared/api/models'; -import { decodeWorkflowCatalogItemDetailResponse } from '@/shared/api/decoders'; +} from "./models"; +import type { WorkflowCatalogItemDetail } from "@/shared/api/models"; +import { decodeWorkflowCatalogItemDetailResponse } from "@/shared/api/runtimeDecoders"; const JSON_HEADERS = { - 'Content-Type': 'application/json', - Accept: 'application/json', + "Content-Type": "application/json", + Accept: "application/json", }; async function studioHostFetch( input: string, - init?: RequestInit, + init?: RequestInit ): Promise { return fetch(input, { - credentials: 'same-origin', + credentials: "same-origin", ...init, }); } function isJsonContentType(contentType: string | null): boolean { - const value = String(contentType || '').toLowerCase(); - return value.includes('application/json') || value.includes('+json'); + const value = String(contentType || "").toLowerCase(); + return value.includes("application/json") || value.includes("+json"); } function trimOptional(value: string | null | undefined): string | undefined { @@ -51,7 +51,7 @@ function trimOptional(value: string | null | undefined): string | undefined { function compactObject>(value: T): T { return Object.fromEntries( - Object.entries(value).filter(([, entry]) => entry !== undefined), + Object.entries(value).filter(([, entry]) => entry !== undefined) ) as T; } @@ -89,7 +89,7 @@ async function requestJson(input: string, init?: RequestInit): Promise { async function requestDecodedJson( input: string, decoder: (value: unknown) => T, - init?: RequestInit, + init?: RequestInit ): Promise { const response = await studioHostFetch(input, init); if (!response.ok) { @@ -106,12 +106,12 @@ async function requestDecodedJson( async function request(input: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers); const isFormDataBody = - typeof FormData !== 'undefined' && init?.body instanceof FormData; - if (!isFormDataBody && !headers.has('Content-Type')) { - headers.set('Content-Type', 'application/json'); + typeof FormData !== "undefined" && init?.body instanceof FormData; + if (!isFormDataBody && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); } - if (!headers.has('Accept')) { - headers.set('Accept', 'application/json'); + if (!headers.has("Accept")) { + headers.set("Accept", "application/json"); } const response = await studioHostFetch(input, { @@ -126,8 +126,8 @@ async function request(input: string, init?: RequestInit): Promise { return undefined as T; } - if (!isJsonContentType(response.headers.get('content-type'))) { - throw new Error('Studio API returned an unexpected response format.'); + if (!isJsonContentType(response.headers.get("content-type"))) { + throw new Error("Studio API returned an unexpected response format."); } return (await response.json()) as T; @@ -137,13 +137,13 @@ async function streamSse( input: string, body: unknown, onFrame: (frame: unknown) => void, - signal?: AbortSignal, + signal?: AbortSignal ): Promise { const response = await studioHostFetch(input, { - method: 'POST', + method: "POST", headers: { - Accept: 'text/event-stream', - 'Content-Type': 'application/json', + Accept: "text/event-stream", + "Content-Type": "application/json", }, body: JSON.stringify(body), signal, @@ -158,28 +158,28 @@ async function streamSse( const reader = response.body.getReader(); const decoder = new TextDecoder(); - let buffer = ''; + let buffer = ""; while (true) { const { done, value } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); - let boundary = buffer.indexOf('\n\n'); + let boundary = buffer.indexOf("\n\n"); while (boundary >= 0) { const block = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const data = block - .split('\n') - .filter((line) => line.startsWith('data:')) + .split("\n") + .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trim()) - .join('\n'); + .join("\n"); - if (data && data !== '[DONE]') { + if (data && data !== "[DONE]") { onFrame(JSON.parse(data) as unknown); } - boundary = buffer.indexOf('\n\n'); + boundary = buffer.indexOf("\n\n"); } if (done) { @@ -189,58 +189,55 @@ async function streamSse( } function normalizeAssistantFrame( - frame: unknown, -): - | { type: string; delta?: string; message?: string } - | null { - if (!frame || typeof frame !== 'object') { + frame: unknown +): { type: string; delta?: string; message?: string } | null { + if (!frame || typeof frame !== "object") { return null; } const candidate = frame as Record; - if (typeof candidate.type === 'string') { + if (typeof candidate.type === "string") { return { type: candidate.type, - delta: - typeof candidate.delta === 'string' ? candidate.delta : undefined, + delta: typeof candidate.delta === "string" ? candidate.delta : undefined, message: - typeof candidate.message === 'string' ? candidate.message : undefined, + typeof candidate.message === "string" ? candidate.message : undefined, }; } if (candidate.textMessageContent) { const payload = candidate.textMessageContent as Record; return { - type: 'TEXT_MESSAGE_CONTENT', - delta: typeof payload.delta === 'string' ? payload.delta : '', + type: "TEXT_MESSAGE_CONTENT", + delta: typeof payload.delta === "string" ? payload.delta : "", }; } if (candidate.textMessageReasoning) { const payload = candidate.textMessageReasoning as Record; return { - type: 'TEXT_MESSAGE_REASONING', - delta: typeof payload.delta === 'string' ? payload.delta : '', + type: "TEXT_MESSAGE_REASONING", + delta: typeof payload.delta === "string" ? payload.delta : "", }; } if (candidate.textMessageEnd) { const payload = candidate.textMessageEnd as Record; return { - type: 'TEXT_MESSAGE_END', - delta: typeof payload.delta === 'string' ? payload.delta : '', - message: typeof payload.message === 'string' ? payload.message : '', + type: "TEXT_MESSAGE_END", + delta: typeof payload.delta === "string" ? payload.delta : "", + message: typeof payload.message === "string" ? payload.message : "", }; } if (candidate.runError) { const payload = candidate.runError as Record; return { - type: 'RUN_ERROR', + type: "RUN_ERROR", message: - typeof payload.message === 'string' + typeof payload.message === "string" ? payload.message - : 'Assistant run failed.', + : "Assistant run failed.", }; } @@ -249,37 +246,39 @@ function normalizeAssistantFrame( export const studioApi = { getAppContext(): Promise { - return requestJson('/api/app/context'); + return requestJson("/api/app/context"); }, getAuthSession(): Promise { - return requestJson('/api/auth/me'); + return requestJson("/api/auth/me"); }, getWorkspaceSettings(): Promise { - return requestJson('/api/workspace/'); + return requestJson("/api/workspace/"); }, listWorkflows(): Promise { - return requestJson('/api/workspace/workflows'); + return requestJson("/api/workspace/workflows"); }, - getTemplateWorkflow(workflowName: string): Promise { + getTemplateWorkflow( + workflowName: string + ): Promise { return requestDecodedJson( `/api/workflows/${encodeURIComponent(workflowName)}`, - decodeWorkflowCatalogItemDetailResponse, + decodeWorkflowCatalogItemDetailResponse ); }, getWorkflow(workflowId: string): Promise { return requestJson( - `/api/workspace/workflows/${encodeURIComponent(workflowId)}`, + `/api/workspace/workflows/${encodeURIComponent(workflowId)}` ); }, saveWorkflow(input: StudioSaveWorkflowInput): Promise { - return requestJson('/api/workspace/workflows', { - method: 'POST', + return requestJson("/api/workspace/workflows", { + method: "POST", headers: JSON_HEADERS, body: JSON.stringify( compactObject({ @@ -289,7 +288,7 @@ export const studioApi = { fileName: trimOptional(input.fileName), yaml: input.yaml, layout: input.layout, - }), + }) ), }); }, @@ -298,8 +297,8 @@ export const studioApi = { yaml: string; availableWorkflowNames?: string[]; }): Promise { - return requestJson('/api/editor/parse-yaml', { - method: 'POST', + return requestJson("/api/editor/parse-yaml", { + method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ yaml: input.yaml, @@ -312,8 +311,8 @@ export const studioApi = { document: StudioWorkflowDocument; availableWorkflowNames?: string[]; }): Promise { - return requestJson('/api/editor/serialize-yaml', { - method: 'POST', + return requestJson("/api/editor/serialize-yaml", { + method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ document: input.document, @@ -323,7 +322,7 @@ export const studioApi = { }, listExecutions(): Promise { - return requestJson('/api/executions/'); + return requestJson("/api/executions/"); }, getExecution(executionId: string): Promise { @@ -331,10 +330,10 @@ export const studioApi = { }, startExecution( - input: StudioStartExecutionInput, + input: StudioStartExecutionInput ): Promise { - return requestJson('/api/executions/', { - method: 'POST', + return requestJson("/api/executions/", { + method: "POST", headers: JSON_HEADERS, body: JSON.stringify( compactObject({ @@ -345,22 +344,25 @@ export const studioApi = { scopeId: trimOptional(input.scopeId), workflowId: trimOptional(input.workflowId), eventFormat: trimOptional(input.eventFormat), - }), + }) ), }); }, stopExecution( executionId: string, - input: { reason?: string | null }, + input: { reason?: string | null } ): Promise { - return requestJson(`/api/executions/${encodeURIComponent(executionId)}/stop`, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify({ - reason: trimOptional(input.reason), - }), - }); + return requestJson( + `/api/executions/${encodeURIComponent(executionId)}/stop`, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + reason: trimOptional(input.reason), + }), + } + ); }, resumeExecution( @@ -370,35 +372,38 @@ export const studioApi = { stepId: string; approved: boolean; userInput?: string | null; - suspensionType: 'human_input' | 'human_approval'; - }, + suspensionType: "human_input" | "human_approval"; + } ): Promise { - return requestJson(`/api/executions/${encodeURIComponent(executionId)}/resume`, { - method: 'POST', - headers: JSON_HEADERS, - body: JSON.stringify({ - runId: input.runId, - stepId: input.stepId, - approved: input.approved, - userInput: trimOptional(input.userInput), - suspensionType: input.suspensionType, - }), - }); + return requestJson( + `/api/executions/${encodeURIComponent(executionId)}/resume`, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + runId: input.runId, + stepId: input.stepId, + approved: input.approved, + userInput: trimOptional(input.userInput), + suspensionType: input.suspensionType, + }), + } + ); }, getConnectorCatalog(): Promise { - return requestJson('/api/connectors/'); + return requestJson("/api/connectors/"); }, getConnectorDraft(): Promise { - return requestJson('/api/connectors/draft'); + return requestJson("/api/connectors/draft"); }, saveConnectorCatalog(input: { - connectors: StudioConnectorCatalog['connectors']; + connectors: StudioConnectorCatalog["connectors"]; }): Promise { - return requestJson('/api/connectors/', { - method: 'PUT', + return requestJson("/api/connectors/", { + method: "PUT", headers: JSON_HEADERS, body: JSON.stringify({ connectors: input.connectors, @@ -407,10 +412,10 @@ export const studioApi = { }, saveConnectorDraft(input: { - draft: StudioConnectorDraftResponse['draft']; + draft: StudioConnectorDraftResponse["draft"]; }): Promise { - return requestJson('/api/connectors/draft', { - method: 'PUT', + return requestJson("/api/connectors/draft", { + method: "PUT", headers: JSON_HEADERS, body: JSON.stringify({ draft: input.draft, @@ -419,33 +424,35 @@ export const studioApi = { }, deleteConnectorDraft(): Promise { - return request('/api/connectors/draft', { - method: 'DELETE', + return request("/api/connectors/draft", { + method: "DELETE", }); }, - importConnectorCatalog(file: File): Promise { + importConnectorCatalog( + file: File + ): Promise { const form = new FormData(); - form.set('file', file, file.name); - return request('/api/connectors/import', { - method: 'POST', + form.set("file", file, file.name); + return request("/api/connectors/import", { + method: "POST", body: form, }); }, getRoleCatalog(): Promise { - return requestJson('/api/roles/'); + return requestJson("/api/roles/"); }, getRoleDraft(): Promise { - return requestJson('/api/roles/draft'); + return requestJson("/api/roles/draft"); }, saveRoleCatalog(input: { - roles: StudioRoleCatalog['roles']; + roles: StudioRoleCatalog["roles"]; }): Promise { - return requestJson('/api/roles/', { - method: 'PUT', + return requestJson("/api/roles/", { + method: "PUT", headers: JSON_HEADERS, body: JSON.stringify({ roles: input.roles, @@ -454,10 +461,10 @@ export const studioApi = { }, saveRoleDraft(input: { - draft: StudioRoleDraftResponse['draft']; + draft: StudioRoleDraftResponse["draft"]; }): Promise { - return requestJson('/api/roles/draft', { - method: 'PUT', + return requestJson("/api/roles/draft", { + method: "PUT", headers: JSON_HEADERS, body: JSON.stringify({ draft: input.draft, @@ -466,27 +473,27 @@ export const studioApi = { }, deleteRoleDraft(): Promise { - return request('/api/roles/draft', { - method: 'DELETE', + return request("/api/roles/draft", { + method: "DELETE", }); }, importRoleCatalog(file: File): Promise { const form = new FormData(); - form.set('file', file, file.name); - return request('/api/roles/import', { - method: 'POST', + form.set("file", file, file.name); + return request("/api/roles/import", { + method: "POST", body: form, }); }, getSettings(): Promise { - return requestJson('/api/settings/'); + return requestJson("/api/settings/"); }, saveSettings(input: StudioSaveSettingsInput): Promise { - return requestJson('/api/settings/', { - method: 'PUT', + return requestJson("/api/settings/", { + method: "PUT", headers: JSON_HEADERS, body: JSON.stringify( compactObject({ @@ -500,9 +507,9 @@ export const studioApi = { endpoint: trimOptional(provider.endpoint), apiKey: trimOptional(provider.apiKey), clearApiKey: provider.clearApiKey ? true : undefined, - }), + }) ), - }), + }) ), }); }, @@ -510,8 +517,8 @@ export const studioApi = { testRuntimeConnection(input: { runtimeBaseUrl?: string | null; }): Promise { - return requestJson('/api/settings/runtime/test', { - method: 'POST', + return requestJson("/api/settings/runtime/test", { + method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ runtimeBaseUrl: trimOptional(input.runtimeBaseUrl), @@ -523,22 +530,25 @@ export const studioApi = { path: string; label?: string | null; }): Promise { - return requestJson('/api/workspace/directories', { - method: 'POST', + return requestJson("/api/workspace/directories", { + method: "POST", headers: JSON_HEADERS, body: JSON.stringify( compactObject({ path: input.path.trim(), label: trimOptional(input.label), - }), + }) ), }); }, removeWorkflowDirectory(directoryId: string): Promise { - return request(`/api/workspace/directories/${encodeURIComponent(directoryId)}`, { - method: 'DELETE', - }); + return request( + `/api/workspace/directories/${encodeURIComponent(directoryId)}`, + { + method: "DELETE", + } + ); }, async authorWorkflow( @@ -552,13 +562,13 @@ export const studioApi = { signal?: AbortSignal; onText?: (text: string) => void; onReasoning?: (text: string) => void; - }, + } ): Promise { - let generatedText = ''; - let reasoningText = ''; + let generatedText = ""; + let reasoningText = ""; await streamSse( - '/api/app/workflow-generator', + "/api/app/workflow-generator", { prompt: input.prompt.trim(), currentYaml: input.currentYaml, @@ -571,30 +581,30 @@ export const studioApi = { return; } - if (normalized.type === 'TEXT_MESSAGE_CONTENT') { - generatedText += normalized.delta || ''; + if (normalized.type === "TEXT_MESSAGE_CONTENT") { + generatedText += normalized.delta || ""; options?.onText?.(generatedText); return; } - if (normalized.type === 'TEXT_MESSAGE_REASONING') { - reasoningText += normalized.delta || ''; + if (normalized.type === "TEXT_MESSAGE_REASONING") { + reasoningText += normalized.delta || ""; options?.onReasoning?.(reasoningText); return; } - if (normalized.type === 'TEXT_MESSAGE_END') { + if (normalized.type === "TEXT_MESSAGE_END") { generatedText = - generatedText || normalized.message || normalized.delta || ''; + generatedText || normalized.message || normalized.delta || ""; options?.onText?.(generatedText); return; } - if (normalized.type === 'RUN_ERROR') { - throw new Error(normalized.message || 'Assistant run failed.'); + if (normalized.type === "RUN_ERROR") { + throw new Error(normalized.message || "Assistant run failed."); } }, - options?.signal, + options?.signal ); return generatedText; diff --git a/apps/aevatar-console-web/src/shared/workflows/catalogVisibility.ts b/apps/aevatar-console-web/src/shared/workflows/catalogVisibility.ts index 222d4124..5c2bbb0a 100644 --- a/apps/aevatar-console-web/src/shared/workflows/catalogVisibility.ts +++ b/apps/aevatar-console-web/src/shared/workflows/catalogVisibility.ts @@ -1,4 +1,4 @@ -import type { WorkflowCatalogItem } from '@/shared/api/models'; +import type { WorkflowCatalogItem } from "@/shared/models/runtime/catalog"; export type WorkflowCatalogOption = { label: string; @@ -6,18 +6,18 @@ export type WorkflowCatalogOption = { }; function trimOptional(value?: string | null): string { - return value?.trim() ?? ''; + return value?.trim() ?? ""; } export function listVisibleWorkflowCatalogItems( - items: readonly WorkflowCatalogItem[], + items: readonly WorkflowCatalogItem[] ): WorkflowCatalogItem[] { return items.filter((item) => item.showInLibrary); } export function findWorkflowCatalogItem( items: readonly WorkflowCatalogItem[], - workflowName?: string | null, + workflowName?: string | null ): WorkflowCatalogItem | undefined { const normalized = trimOptional(workflowName); if (!normalized) { @@ -29,19 +29,19 @@ export function findWorkflowCatalogItem( export function resolveWorkflowCatalogSelection( items: readonly WorkflowCatalogItem[], - currentWorkflowName?: string | null, + currentWorkflowName?: string | null ): string { const normalized = trimOptional(currentWorkflowName); if (normalized && items.some((item) => item.name === normalized)) { return normalized; } - return listVisibleWorkflowCatalogItems(items)[0]?.name ?? ''; + return listVisibleWorkflowCatalogItems(items)[0]?.name ?? ""; } export function buildWorkflowCatalogOptions( items: readonly WorkflowCatalogItem[], - currentWorkflowName?: string | null, + currentWorkflowName?: string | null ): WorkflowCatalogOption[] { const visibleItems = listVisibleWorkflowCatalogItems(items); const options = visibleItems.map((item) => ({