From 2fec3b30619ea7d92ff4cfc158183c3b42c317aa Mon Sep 17 00:00:00 2001 From: SPINN Date: Mon, 31 Aug 2026 11:23:25 +0800 Subject: [PATCH 1/2] demo: add temi OpenAPI scenario runner --- demos/temi-openapi-scenario-runner/.gitignore | 3 + demos/temi-openapi-scenario-runner/README.md | 44 + demos/temi-openapi-scenario-runner/index.html | 19 + demos/temi-openapi-scenario-runner/logo.svg | 17 + .../package-lock.json | 1158 +++++++++ .../temi-openapi-scenario-runner/package.json | 20 + demos/temi-openapi-scenario-runner/src/api.ts | 496 ++++ .../src/composer.ts | 580 +++++ .../src/discovery.ts | 433 ++++ .../src/lifecycle.ts | 732 ++++++ .../temi-openapi-scenario-runner/src/main.ts | 2089 +++++++++++++++++ .../src/orchestrator.ts | 235 ++ .../src/run-state.ts | 169 ++ .../src/styles.css | 1579 +++++++++++++ .../src/timeline.ts | 444 ++++ .../tsconfig.json | 14 + .../vite.config.mts | 16 + 17 files changed, 8048 insertions(+) create mode 100644 demos/temi-openapi-scenario-runner/.gitignore create mode 100644 demos/temi-openapi-scenario-runner/README.md create mode 100644 demos/temi-openapi-scenario-runner/index.html create mode 100644 demos/temi-openapi-scenario-runner/logo.svg create mode 100644 demos/temi-openapi-scenario-runner/package-lock.json create mode 100644 demos/temi-openapi-scenario-runner/package.json create mode 100644 demos/temi-openapi-scenario-runner/src/api.ts create mode 100644 demos/temi-openapi-scenario-runner/src/composer.ts create mode 100644 demos/temi-openapi-scenario-runner/src/discovery.ts create mode 100644 demos/temi-openapi-scenario-runner/src/lifecycle.ts create mode 100644 demos/temi-openapi-scenario-runner/src/main.ts create mode 100644 demos/temi-openapi-scenario-runner/src/orchestrator.ts create mode 100644 demos/temi-openapi-scenario-runner/src/run-state.ts create mode 100644 demos/temi-openapi-scenario-runner/src/styles.css create mode 100644 demos/temi-openapi-scenario-runner/src/timeline.ts create mode 100644 demos/temi-openapi-scenario-runner/tsconfig.json create mode 100644 demos/temi-openapi-scenario-runner/vite.config.mts diff --git a/demos/temi-openapi-scenario-runner/.gitignore b/demos/temi-openapi-scenario-runner/.gitignore new file mode 100644 index 0000000..746087d --- /dev/null +++ b/demos/temi-openapi-scenario-runner/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.log diff --git a/demos/temi-openapi-scenario-runner/README.md b/demos/temi-openapi-scenario-runner/README.md new file mode 100644 index 0000000..042c427 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/README.md @@ -0,0 +1,44 @@ +# temi OpenAPI scenario runner + +Client-only browser demo for composing and running a small temi robot scenario. +It demonstrates the complete operator flow: + +`verify → discover → validate → play → poll → stop` + +The interface starts in Chinese and includes an English option. It supports the +Production and Integration temi API environments, robot selection, MOVEMENT, +SPEAK, and START_CALL actions, final run confirmation, and status polling. + +## Run locally + +Requires Node.js 20 or newer. From this directory: + +```bash +npm ci +npm run dev +``` + +Open the URL printed by Vite. To verify the production build instead: + +```bash +npm run build +npm run preview +``` + +The demo is served over HTTP; do not open the source or `dist/index.html` with +`file://`. + +## Token safety + +Enter your own Organization Access Token (OAT) in the page. The demo keeps it +only in page memory and sends it as `x-api-key` directly to the selected temi +API origin. It does not use a backend, cookies, Web Storage, analytics, or +third-party runtime code. The token is cleared when the connection is reset, +the environment changes, a request returns 401, or the page closes. + +Never commit an OAT, put one in a URL, or include one in logs, screenshots, or +support reports. Play and Stop can control a real PRO robot; use explicit +operator confirmation and a safe test area. + +For the API contract and endpoint reference, see the +[temi OpenAPI documentation](https://openapi-docs.robotemi.com). diff --git a/demos/temi-openapi-scenario-runner/index.html b/demos/temi-openapi-scenario-runner/index.html new file mode 100644 index 0000000..298f4c1 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/index.html @@ -0,0 +1,19 @@ + + + + + + + 场景运行 | temi Developer + + + +
+ +
+ + + diff --git a/demos/temi-openapi-scenario-runner/logo.svg b/demos/temi-openapi-scenario-runner/logo.svg new file mode 100644 index 0000000..a119942 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/demos/temi-openapi-scenario-runner/package-lock.json b/demos/temi-openapi-scenario-runner/package-lock.json new file mode 100644 index 0000000..8b775bd --- /dev/null +++ b/demos/temi-openapi-scenario-runner/package-lock.json @@ -0,0 +1,1158 @@ +{ + "name": "temi-openapi-scenario-runner-demo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "temi-openapi-scenario-runner-demo", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.19.43", + "typescript": "^5.9.3", + "vite": "^6.4.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/demos/temi-openapi-scenario-runner/package.json b/demos/temi-openapi-scenario-runner/package.json new file mode 100644 index 0000000..62a6ef4 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/package.json @@ -0,0 +1,20 @@ +{ + "name": "temi-openapi-scenario-runner-demo", + "private": true, + "version": "0.1.0", + "description": "Client-only temi OpenAPI scenario runner demo", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "vite", + "build": "npm run typecheck && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "devDependencies": { + "@types/node": "^20.19.43", + "typescript": "^5.9.3", + "vite": "^6.4.3" + } +} diff --git a/demos/temi-openapi-scenario-runner/src/api.ts b/demos/temi-openapi-scenario-runner/src/api.ts new file mode 100644 index 0000000..4dbefc5 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/src/api.ts @@ -0,0 +1,496 @@ +export const ENVIRONMENTS = ["production", "integration"] as const; +export type Environment = (typeof ENVIRONMENTS)[number]; + +export const ENVIRONMENT_BASE_URLS: Readonly> = Object.freeze({ + production: "https://api.robotemi.com/openapi/v1", + integration: "https://integration.dev.temi.cloud/openapi/v1", +}); + +export type VerifyResponse = { + status?: string; + organizationId?: string; + tokenId?: string; + scopes?: string[]; + robotScope?: "all" | "selected"; + serialNumbers?: string[]; +}; + +export type RobotSummary = { + teminame?: string; + serialNumber: string; +}; + +export type RobotsResponse = { + robots?: RobotSummary[]; +}; + +export type RobotMovementStatus = { + type?: string; + status?: string; + location?: string; +}; + +export type RobotSequenceStatus = { + status: string; + name?: string; + step?: number; + total?: number; + sequenceId?: string; +}; + +export type RobotCallStatus = { + status: string; + hostId?: string; +}; + +export type RobotBatteryStatus = { + level?: number; + isCharging?: boolean; +}; + +export type RobotStatus = RobotSummary & { + status: "online" | "offline" | "busy" | "privacy"; + movement?: RobotMovementStatus; + sequence?: RobotSequenceStatus; + call?: RobotCallStatus; + battery?: RobotBatteryStatus; +}; + +export type Location = { + name?: string; +}; + +export type LocationsResponse = { + serialNumber?: string; + mapId?: string; + mapName?: string; + locations?: Location[]; +}; + +export type StartCallContact = { + temiId: string; + platform?: "both" | "mobile" | "web"; +}; + +export type Contact = { + temiId?: string; + name?: string; + kind?: "member"; + source?: "organization"; +}; + +export type ContactsResponse = { + contacts?: Contact[]; +}; + +export type SequenceActionType = "MOVEMENT" | "SPEAK" | "START_CALL"; +export type ContactId = string | StartCallContact; + +export type MovementExtra = { + locationName: string; + speed?: "high" | "medium"; + onError?: "skip" | "abort"; + personalLead?: boolean; +}; + +export type SpeakExtra = { + tts: string; + language?: string; + display?: "none" | "text" | "talk"; + loop?: boolean; +}; + +export type SequenceAction = { + type: SequenceActionType; + step?: number; + startStep?: number; + endStep?: number; + finalInEndStep?: boolean; + delay?: number; + actionId?: string; + location?: string; + tts?: string; + language?: string; + display?: "none" | "text" | "talk"; + contactIds?: ContactId[]; + platform?: "both" | "mobile" | "web"; + extra?: MovementExtra | SpeakExtra; +}; + +export type SequenceStopBy = { + touchScreen?: boolean; + wakeup?: boolean; +}; + +export type PlaySequenceRequest = { + serialNumber: string; + actions: SequenceAction[]; + name?: string; + description?: string; + volume?: number; + fixedVolume?: boolean; + startFromStep?: number; + repeatSequence?: number; + stopBy?: SequenceStopBy; +}; + +export type PlaySequenceResponse = { + status?: string; + sequenceId?: string; + serialNumber?: string; +}; + +export type StopSequenceRequest = { + sequenceId: string; +}; + +export type StopSequenceResponse = { + status?: string; + sequenceId?: string; + serialNumber?: string; +}; + +export type ValidateSequenceResponse = { + status?: string; +}; + +export type ErrorCode = + | "validation_error" + | "robot_privacy" + | "robot_offline" + | "robot_busy" + | "not_found" + | "sequence_not_running" + | "internal_error"; + +export type ValidationHint = "sequence" | "MOVEMENT" | "SPEAK" | "START_CALL"; + +export type ErrorResponse = { + error: string; + error_code: ErrorCode; +}; + +export type TemiApiRequestOptions = Readonly<{ + signal?: AbortSignal; + onResponseStatus?: (status: number) => void; + onUnauthorized?: () => void; +}>; + +export type TemiApiClientOptions = Readonly<{ + onReadableResponse?: (status: number) => void; + onTransportFailure?: () => void; +}>; + +export type TemiApiErrorKind = + | "unauthorized" + | "http" + | "network" + | "timeout" + | "aborted" + | "invalid_response"; + +export class TemiApiError extends Error { + readonly kind: TemiApiErrorKind; + readonly status: number | null; + readonly code: ErrorCode | null; + readonly validationHint: ValidationHint | null; + + constructor( + kind: TemiApiErrorKind, + status: number | null, + code: ErrorCode | null = null, + validationHint: ValidationHint | null = null, + ) { + super(errorMessage(kind, status)); + this.name = "TemiApiError"; + this.kind = kind; + this.status = status; + this.code = code; + this.validationHint = validationHint; + } +} + +export function isEnvironment(value: unknown): value is Environment { + return value === "production" || value === "integration"; +} + +export class TemiApiClient { + #oat: string | null; + #environment: Environment; + #onReadableResponse: ((status: number) => void) | undefined; + #onTransportFailure: (() => void) | undefined; + + constructor( + environment: Environment, + oat: string, + options: TemiApiClientOptions = {}, + ) { + if (!isEnvironment(environment)) { + throw new Error("Unsupported environment"); + } + if (typeof oat !== "string" || oat.trim().length === 0) { + throw new Error("OAT is required"); + } + this.#environment = environment; + this.#oat = oat; + this.#onReadableResponse = options.onReadableResponse; + this.#onTransportFailure = options.onTransportFailure; + } + + clearToken(): void { + this.#oat = null; + } + + hasToken(): boolean { + return this.#oat !== null; + } + + verify(options?: TemiApiRequestOptions): Promise { + return this.request("GET", "/verify", undefined, options); + } + + listRobots(options?: TemiApiRequestOptions): Promise { + return this.request("GET", "/robots", undefined, options); + } + + getRobotStatus(serialNumber: string, options?: TemiApiRequestOptions): Promise { + return this.request( + "GET", + `/robots/${encodeURIComponent(serialNumber)}`, + undefined, + options, + ); + } + + listRobotLocations( + serialNumber: string, + options?: TemiApiRequestOptions, + ): Promise { + return this.request( + "GET", + `/robots/${encodeURIComponent(serialNumber)}/locations`, + undefined, + options, + ); + } + + listRobotContacts( + serialNumber: string, + options?: TemiApiRequestOptions, + ): Promise { + return this.request( + "GET", + `/robots/${encodeURIComponent(serialNumber)}/contacts`, + undefined, + options, + ); + } + + validateSequence( + request: PlaySequenceRequest, + options?: TemiApiRequestOptions, + ): Promise { + return this.request("POST", "/sequences/validate", request, options); + } + + playSequence( + request: PlaySequenceRequest, + options?: TemiApiRequestOptions, + ): Promise { + return this.request("POST", "/sequences/play", request, options); + } + + stopSequence( + request: StopSequenceRequest, + options?: TemiApiRequestOptions, + ): Promise { + return this.request("POST", "/sequences/stop", request, options); + } + + private async request( + method: "GET" | "POST", + path: string, + body?: unknown, + options?: TemiApiRequestOptions, + ): Promise { + const oat = this.#oat; + if (oat === null) { + throw new TemiApiError("unauthorized", 401); + } + + const headers: Record = { "x-api-key": oat }; + if (method === "POST") { + headers["Content-Type"] = "application/json"; + } + + let response: Response; + try { + response = await fetch(`${ENVIRONMENT_BASE_URLS[this.#environment]}${path}`, { + method, + headers, + credentials: "omit", + ...(options?.signal === undefined ? {} : { signal: options.signal }), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch (error: unknown) { + if (options?.signal?.aborted || isAbortError(error)) { + throw new TemiApiError( + options?.signal?.reason === "timeout" ? "timeout" : "aborted", + null, + ); + } + this.#onTransportFailure?.(); + throw new TemiApiError("network", null); + } + + options?.onResponseStatus?.(response.status); + this.#onReadableResponse?.(response.status); + + let payload: unknown = null; + try { + payload = await response.json(); + } catch { + if (response.status === 401) { + options?.onUnauthorized?.(); + this.clearToken(); + throw new TemiApiError("unauthorized", 401); + } + throw new TemiApiError("invalid_response", response.status); + } + + if (!response.ok) { + if (response.status === 401) { + options?.onUnauthorized?.(); + this.clearToken(); + throw new TemiApiError("unauthorized", 401); + } + const code = readErrorCode(payload); + throw new TemiApiError( + "http", + response.status, + code, + code === "validation_error" ? readValidationHint(payload) : null, + ); + } + + if (!isValidSuccessPayload(path, payload)) { + throw new TemiApiError("invalid_response", response.status); + } + + return redactToken(payload, oat) as T; + } +} + +function errorMessage(kind: TemiApiErrorKind, status: number | null): string { + if (kind === "unauthorized") return "Request unauthorized."; + if (kind === "network") return "Temi API request could not be completed."; + if (kind === "timeout") return "Temi API request timed out."; + if (kind === "aborted") return "Temi API request was aborted."; + if (kind === "invalid_response") return "Temi API returned an invalid JSON response."; + return `Temi API request failed with HTTP ${status ?? "unknown"}.`; +} + +export function isAbortError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: unknown }).name === "AbortError" + ); +} + +function readErrorCode(payload: unknown): ErrorCode | null { + if (typeof payload !== "object" || payload === null || !("error_code" in payload)) { + return null; + } + const code = payload.error_code; + return isErrorCode(code) ? code : null; +} + +function readValidationHint(payload: unknown): ValidationHint | null { + if (!isRecord(payload) || typeof payload.error !== "string") return null; + const error = payload.error.toLowerCase(); + if (error.includes("contact") || error.includes("start_call")) return "START_CALL"; + if (error.includes("location") || error.includes("movement")) return "MOVEMENT"; + if (error.includes("tts") || error.includes("speak")) return "SPEAK"; + if (error.includes("action") || error.includes("step") || error.includes("sequence")) { + return "sequence"; + } + return null; +} + +function isValidSuccessPayload(path: string, payload: unknown): boolean { + if (!isRecord(payload)) return false; + if (path === "/sequences/play") { + return typeof payload.sequenceId === "string" && payload.sequenceId.trim().length > 0; + } + if (path === "/robots" && "robots" in payload) { + return isRobotArray(payload.robots); + } + if (path.endsWith("/locations") && "locations" in payload) { + return isObjectArray(payload.locations); + } + if (path.endsWith("/contacts") && "contacts" in payload) { + return isObjectArray(payload.contacts); + } + if (path.startsWith("/robots/")) { + return isRobotStatus(payload); + } + return true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isObjectArray(value: unknown): boolean { + return Array.isArray(value) && value.every((item) => isRecord(item)); +} + +function isRobotArray(value: unknown): boolean { + return ( + Array.isArray(value) && + value.every( + (item) => isRecord(item) && typeof item.serialNumber === "string", + ) + ); +} + +function isRobotStatus(value: Record): boolean { + return ( + typeof value.serialNumber === "string" && + /^[0-9]{11}$/.test(value.serialNumber) && + isRobotStatusValue(value.status) + ); +} + +function isRobotStatusValue(value: unknown): value is RobotStatus["status"] { + return value === "online" || value === "offline" || value === "busy" || value === "privacy"; +} + +function isErrorCode(value: unknown): value is ErrorCode { + return ( + value === "validation_error" || + value === "robot_privacy" || + value === "robot_offline" || + value === "robot_busy" || + value === "not_found" || + value === "sequence_not_running" || + value === "internal_error" + ); +} + +function redactToken(value: unknown, oat: string): unknown { + if (typeof value === "string") { + return value.split(oat).join("[redacted]"); + } + if (Array.isArray(value)) { + return value.map((item) => redactToken(item, oat)); + } + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [redactToken(key, oat), redactToken(item, oat)]), + ); + } + return value; +} diff --git a/demos/temi-openapi-scenario-runner/src/composer.ts b/demos/temi-openapi-scenario-runner/src/composer.ts new file mode 100644 index 0000000..a7bb001 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/src/composer.ts @@ -0,0 +1,580 @@ +import { + TemiApiError, + type ContactId, + type Environment, + type ErrorCode, + type PlaySequenceRequest, + type SequenceAction, + type SequenceActionType, + type TemiApiClient, + type TemiApiErrorKind, + type ValidationHint, +} from "./api"; +import { RequestOrchestrator } from "./orchestrator"; + +export type ComposerOption = Readonly<{ + value: string; + label: string; +}>; + +export type ComposerContext = Readonly<{ + environment: Environment; + serialNumber: string; + /** null means the corresponding discovery request did not succeed. */ + locations: readonly ComposerOption[] | null; + /** null means the corresponding discovery request did not succeed. */ + contacts: readonly ComposerOption[] | null; +}>; + +export type SequenceActionDraft = + | Readonly<{ + id: string; + type: "MOVEMENT"; + location: string; + }> + | Readonly<{ + id: string; + type: "SPEAK"; + tts: string; + }> + | Readonly<{ + id: string; + type: "START_CALL"; + contactIds: readonly string[]; + }>; + +export type ComposerValidationIssue = Readonly<{ + actionId: string | null; + step: number | null; + field: "sequence" | "location" | "tts" | "contactIds" | "step"; + message: string; +}>; + +export type ComposerValidationError = Readonly<{ + kind: TemiApiErrorKind | "unknown"; + status: number | null; + code: ErrorCode | null; + validationHint: ValidationHint | null; + actionSteps: readonly number[]; +}>; + +export type SequenceComposerValidation = Readonly<{ + status: "unvalidated" | "pending" | "succeeded" | "failed"; + issues: readonly ComposerValidationIssue[]; + error: ComposerValidationError | null; +}>; + +export type SequenceComposerState = Readonly<{ + context: ComposerContext | null; + actions: readonly SequenceActionDraft[]; + validation: SequenceComposerValidation; +}>; + +export type ComposerInspection = Readonly<{ + valid: boolean; + request: PlaySequenceRequest | null; + issues: readonly ComposerValidationIssue[]; +}>; + +export type SequenceComposerOptions = Readonly<{ + onChange?: (state: SequenceComposerState) => void; +}>; + +export type ComposerValidationClient = Pick; + +export class SequenceComposerError extends Error { + readonly kind: "not_ready" | "resource_unavailable" | "validation_pending" | "unknown_action"; + + constructor( + kind: SequenceComposerError["kind"], + message: string, + ) { + super(message); + this.name = "SequenceComposerError"; + this.kind = kind; + } +} + +export class SequenceComposer { + #context: ComposerContext | null = null; + #actions: SequenceActionDraft[] = []; + #validation: SequenceComposerValidation = unvalidatedValidation(); + #validatedRequest: PlaySequenceRequest | null = null; + #pendingValidation: Promise | null = null; + #version = 0; + #nextActionId = 1; + #onChange: ((state: SequenceComposerState) => void) | undefined; + + constructor(options: SequenceComposerOptions = {}) { + this.#onChange = options.onChange; + } + + get state(): SequenceComposerState { + return this.snapshot(); + } + + setContext(context: ComposerContext): void { + if (this.#pendingValidation !== null) { + throw new SequenceComposerError( + "validation_pending", + "The sequence cannot change while validation is pending.", + ); + } + + const nextContext = normaliseContext(context); + const previousContext = this.#context; + const contextChanged = !sameContext(previousContext, nextContext); + if (!contextChanged) return; + + const robotChanged = + previousContext?.environment !== nextContext.environment || + previousContext?.serialNumber !== nextContext.serialNumber; + this.#context = nextContext; + if (robotChanged) { + this.#actions = []; + } + this.invalidateValidation(); + } + + addAction(type: SequenceActionType): string { + this.assertEditable(); + const context = this.requireContext(); + ensureActionResource(context, type); + + const id = `action-${this.#nextActionId++}`; + if (type === "MOVEMENT") { + this.#actions.push({ id, type, location: "" }); + } else if (type === "SPEAK") { + this.#actions.push({ id, type, tts: "" }); + } else { + this.#actions.push({ id, type, contactIds: [] }); + } + this.invalidateValidation(); + return id; + } + + removeAction(id: string): void { + this.assertEditable(); + const index = this.actionIndex(id); + this.#actions.splice(index, 1); + this.invalidateValidation(); + } + + moveAction(id: string, direction: "up" | "down"): void { + this.assertEditable(); + const index = this.actionIndex(id); + const nextIndex = direction === "up" ? index - 1 : index + 1; + if (nextIndex < 0 || nextIndex >= this.#actions.length) return; + const current = this.#actions[index]; + this.#actions[index] = this.#actions[nextIndex]; + this.#actions[nextIndex] = current; + this.invalidateValidation(); + } + + setMovementLocation(id: string, location: string): void { + this.assertEditable(); + const action = this.action(id); + if (action.type !== "MOVEMENT") { + throw new SequenceComposerError("unknown_action", "The selected action is not MOVEMENT."); + } + if (action.location === location) return; + this.#actions[this.actionIndex(id)] = { ...action, location }; + this.invalidateValidation(); + } + + setSpeakText(id: string, tts: string): void { + this.assertEditable(); + const action = this.action(id); + if (action.type !== "SPEAK") { + throw new SequenceComposerError("unknown_action", "The selected action is not SPEAK."); + } + if (action.tts === tts) return; + this.#actions[this.actionIndex(id)] = { ...action, tts }; + this.invalidateValidation(); + } + + setCallContacts(id: string, contactIds: readonly string[]): void { + this.assertEditable(); + const action = this.action(id); + if (action.type !== "START_CALL") { + throw new SequenceComposerError("unknown_action", "The selected action is not START_CALL."); + } + if (sameStringArray(action.contactIds, contactIds)) return; + this.#actions[this.actionIndex(id)] = { ...action, contactIds: [...contactIds] }; + this.invalidateValidation(); + } + + previewRequest(): PlaySequenceRequest | null { + if (this.#context === null) return null; + return buildPlaySequenceRequest(this.#context.serialNumber, this.#actions); + } + + inspect(): ComposerInspection { + const request = this.previewRequest(); + if (this.#context === null || request === null) { + return { + valid: false, + request: null, + issues: [issue(null, null, "sequence", "Select a robot before preparing a sequence.")], + }; + } + + const issues = collectValidationIssues(this.#context, this.#actions, request); + return { + valid: issues.length === 0, + request: issues.length === 0 ? request : null, + issues, + }; + } + + validate( + client: ComposerValidationClient, + orchestrator: RequestOrchestrator, + ): Promise { + if (this.#pendingValidation !== null) return this.#pendingValidation; + + const inspection = this.inspect(); + if (!inspection.valid || inspection.request === null) { + this.#validatedRequest = null; + this.#validation = freezeValidation({ + status: "failed", + issues: inspection.issues, + error: null, + }); + this.notify(); + return Promise.resolve(this.#validation); + } + + const request = inspection.request; + const version = this.#version; + this.#validatedRequest = null; + this.#validation = freezeValidation({ status: "pending", issues: [], error: null }); + this.notify(); + + let pending: Promise; + pending = orchestrator + .execute( + { + phase: "validating", + operation: "validateSequence", + requestSummary: request, + }, + (options) => client.validateSequence(request, options), + ) + .then( + () => { + if (version !== this.#version) return this.#validation; + this.#validatedRequest = cloneRequest(request); + this.#validation = freezeValidation({ status: "succeeded", issues: [], error: null }); + this.notify(); + return this.#validation; + }, + (error: unknown) => { + if (version === this.#version) { + this.#validatedRequest = null; + this.#validation = freezeValidation({ + status: "failed", + issues: [], + error: safeValidationError(error, request), + }); + this.notify(); + } + throw error; + }, + ) + .finally(() => { + if (this.#pendingValidation === pending) { + this.#pendingValidation = null; + } + }); + this.#pendingValidation = pending; + return pending; + } + + getValidatedRequest(): PlaySequenceRequest | null { + return this.#validatedRequest === null ? null : cloneRequest(this.#validatedRequest); + } + + private requireContext(): ComposerContext { + if (this.#context === null) { + throw new SequenceComposerError("not_ready", "Select a robot before adding an action."); + } + return this.#context; + } + + private assertEditable(): void { + if (this.#pendingValidation !== null) { + throw new SequenceComposerError( + "validation_pending", + "The sequence cannot change while validation is pending.", + ); + } + } + + private action(id: string): SequenceActionDraft { + const action = this.#actions.find((candidate) => candidate.id === id); + if (action === undefined) { + throw new SequenceComposerError("unknown_action", `Unknown sequence action: ${id}.`); + } + return action; + } + + private actionIndex(id: string): number { + const index = this.#actions.findIndex((candidate) => candidate.id === id); + if (index < 0) { + throw new SequenceComposerError("unknown_action", `Unknown sequence action: ${id}.`); + } + return index; + } + + private invalidateValidation(): void { + this.#version += 1; + this.#validatedRequest = null; + this.#validation = unvalidatedValidation(); + this.notify(); + } + + private snapshot(): SequenceComposerState { + return Object.freeze({ + context: this.#context, + actions: Object.freeze(this.#actions.map(freezeDraft)), + validation: this.#validation, + }); + } + + private notify(): void { + this.#onChange?.(this.snapshot()); + } +} + +export function buildPlaySequenceRequest( + serialNumber: string, + actions: readonly SequenceActionDraft[], +): PlaySequenceRequest { + if (serialNumber.trim().length === 0) { + throw new SequenceComposerError("not_ready", "A selected robot serialNumber is required."); + } + return { + serialNumber, + actions: actions.map((action, index) => actionToSequenceAction(action, index + 1)), + }; +} + +function actionToSequenceAction(action: SequenceActionDraft, step: number): SequenceAction { + if (action.type === "MOVEMENT") { + return { type: action.type, step, location: action.location }; + } + if (action.type === "SPEAK") { + return { type: action.type, step, tts: action.tts }; + } + return { + type: action.type, + step, + contactIds: action.contactIds.map((contactId): ContactId => contactId), + }; +} + +function collectValidationIssues( + context: ComposerContext, + actions: readonly SequenceActionDraft[], + request: PlaySequenceRequest, +): readonly ComposerValidationIssue[] { + const issues: ComposerValidationIssue[] = []; + if (actions.length === 0) { + issues.push(issue(null, null, "sequence", "Add at least one action before validation.")); + return issues; + } + + if (!hasContiguousSteps(request)) { + issues.push(issue(null, null, "step", "Sequence steps must start at 1 and be consecutive.")); + } + + actions.forEach((action, index) => { + const step = index + 1; + if (action.type === "MOVEMENT") { + if (context.locations === null) { + issues.push(issue(action.id, step, "location", "Locations must load successfully before MOVEMENT can be validated.")); + } else if (!hasOption(context.locations, action.location)) { + issues.push(issue(action.id, step, "location", "Choose a location from the current robot locations.")); + } + } + + if (action.type === "SPEAK" && action.tts.trim().length === 0) { + issues.push(issue(action.id, step, "tts", "SPEAK requires non-empty tts.")); + } + + if (action.type === "START_CALL") { + if (context.contacts === null) { + issues.push(issue(action.id, step, "contactIds", "Contacts must load successfully before START_CALL can be validated.")); + return; + } + if (action.contactIds.length === 0) { + issues.push(issue(action.id, step, "contactIds", "Choose at least one contact for START_CALL.")); + return; + } + const trimmedIds = action.contactIds.map((contactId) => contactId.trim()); + if (trimmedIds.some((contactId) => contactId.length === 0)) { + issues.push(issue(action.id, step, "contactIds", "START_CALL contacts cannot be empty.")); + } + if (new Set(trimmedIds).size !== trimmedIds.length) { + issues.push(issue(action.id, step, "contactIds", "START_CALL contacts must be unique.")); + } + if (trimmedIds.some((contactId) => !hasOption(context.contacts!, contactId))) { + issues.push(issue(action.id, step, "contactIds", "Choose contacts from the current robot contacts.")); + } + } + }); + + return issues; +} + +function hasContiguousSteps(request: PlaySequenceRequest): boolean { + return request.actions.length > 0 && request.actions.every((action, index) => action.step === index + 1); +} + +function ensureActionResource(context: ComposerContext, type: SequenceActionType): void { + if (type === "MOVEMENT" && (context.locations === null || context.locations.length === 0)) { + throw new SequenceComposerError( + "resource_unavailable", + "MOVEMENT requires successfully loaded robot locations.", + ); + } + if (type === "START_CALL" && (context.contacts === null || context.contacts.length === 0)) { + throw new SequenceComposerError( + "resource_unavailable", + "START_CALL requires successfully loaded robot contacts.", + ); + } +} + +function normaliseContext(context: ComposerContext): ComposerContext { + if (context.serialNumber.trim().length === 0) { + throw new SequenceComposerError("not_ready", "A selected robot serialNumber is required."); + } + return Object.freeze({ + environment: context.environment, + serialNumber: context.serialNumber, + locations: normaliseOptions(context.locations), + contacts: normaliseOptions(context.contacts), + }); +} + +function normaliseOptions(options: readonly ComposerOption[] | null): readonly ComposerOption[] | null { + if (options === null) return null; + const seen = new Set(); + const normalised: ComposerOption[] = []; + for (const option of options) { + if (option.value.trim().length === 0 || seen.has(option.value)) continue; + seen.add(option.value); + normalised.push(Object.freeze({ + value: option.value, + label: option.label.trim().length === 0 ? option.value : option.label, + })); + } + return Object.freeze(normalised); +} + +function sameContext(left: ComposerContext | null, right: ComposerContext): boolean { + if (left === null) return false; + return ( + left.environment === right.environment && + left.serialNumber === right.serialNumber && + sameOptions(left.locations, right.locations) && + sameOptions(left.contacts, right.contacts) + ); +} + +function sameOptions( + left: readonly ComposerOption[] | null, + right: readonly ComposerOption[] | null, +): boolean { + if (left === null || right === null) return left === right; + return left.length === right.length && left.every( + (option, index) => option.value === right[index]?.value && option.label === right[index]?.label, + ); +} + +function hasOption(options: readonly ComposerOption[], value: string): boolean { + return options.some((option) => option.value === value); +} + +function sameStringArray(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function issue( + actionId: string | null, + step: number | null, + field: ComposerValidationIssue["field"], + message: string, +): ComposerValidationIssue { + return Object.freeze({ actionId, step, field, message }); +} + +function safeValidationError( + error: unknown, + request: PlaySequenceRequest, +): ComposerValidationError { + if (error instanceof TemiApiError) { + return Object.freeze({ + kind: error.kind, + status: error.status, + code: error.code, + validationHint: error.validationHint, + actionSteps: matchingActionSteps(request, error.validationHint), + }); + } + return Object.freeze({ + kind: "unknown", + status: null, + code: null, + validationHint: null, + actionSteps: [], + }); +} + +function matchingActionSteps( + request: PlaySequenceRequest, + hint: ValidationHint | null, +): readonly number[] { + if (hint === null) return []; + return request.actions.flatMap((action, index) => { + if (hint !== "sequence" && action.type !== hint) return []; + return [action.step ?? index + 1]; + }); +} + +function unvalidatedValidation(): SequenceComposerValidation { + return freezeValidation({ status: "unvalidated", issues: [], error: null }); +} + +function freezeValidation(validation: { + status: SequenceComposerValidation["status"]; + issues: readonly ComposerValidationIssue[]; + error: ComposerValidationError | null; +}): SequenceComposerValidation { + return Object.freeze({ + status: validation.status, + issues: Object.freeze([...validation.issues]), + error: validation.error, + }); +} + +function freezeDraft(action: SequenceActionDraft): SequenceActionDraft { + if (action.type === "START_CALL") { + return Object.freeze({ ...action, contactIds: Object.freeze([...action.contactIds]) }); + } + return Object.freeze({ ...action }); +} + +function cloneRequest(request: PlaySequenceRequest): PlaySequenceRequest { + return { + serialNumber: request.serialNumber, + actions: request.actions.map((action) => ({ + ...action, + ...(action.contactIds === undefined + ? {} + : { contactIds: action.contactIds.map((contactId) => typeof contactId === "string" ? contactId : { ...contactId }) }), + })), + }; +} diff --git a/demos/temi-openapi-scenario-runner/src/discovery.ts b/demos/temi-openapi-scenario-runner/src/discovery.ts new file mode 100644 index 0000000..aade786 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/src/discovery.ts @@ -0,0 +1,433 @@ +import { + TemiApiError, + type ContactsResponse, + type ErrorCode, + type LocationsResponse, + type RobotStatus, + type RobotSummary, + type RobotsResponse, + type TemiApiErrorKind, + type TemiApiClient, + type VerifyResponse, +} from "./api"; +import { RequestOrchestrator } from "./orchestrator"; +import type { RunState } from "./run-state"; + +export type DiscoveryClient = Pick< + TemiApiClient, + "verify" | "listRobots" | "getRobotStatus" | "listRobotLocations" | "listRobotContacts" +>; + +export type DiscoverySlotStatus = "idle" | "pending" | "succeeded" | "failed"; + +export type DiscoveryFailure = Readonly<{ + kind: TemiApiErrorKind | "unknown"; + status: number | null; + code: ErrorCode | null; +}>; + +export type DiscoverySlot = Readonly<{ + status: DiscoverySlotStatus; + data: T | null; + failure: DiscoveryFailure | null; + receivedAt: number | null; +}>; + +export type DiscoveryState = Readonly<{ + verify: DiscoverySlot; + robots: DiscoverySlot; + selectedRobot: RobotSummary | null; + status: DiscoverySlot; + locations: DiscoverySlot; + contacts: DiscoverySlot; +}>; + +export type DiscoveryControllerOptions = Readonly<{ + now?: () => number; + onChange?: (state: DiscoveryState) => void; +}>; + +export class RobotSelectionError extends Error { + readonly kind = "robot_not_found"; + + constructor() { + super("The selected robot is not in the current robot list."); + this.name = "RobotSelectionError"; + } +} + +export class RobotDiscoveryController { + #client: DiscoveryClient; + #orchestrator: RequestOrchestrator; + #now: () => number; + #onChange: ((state: DiscoveryState) => void) | undefined; + #state: DiscoveryState = initialDiscoveryState(); + #selectionVersion = 0; + + constructor( + client: DiscoveryClient, + orchestrator: RequestOrchestrator, + options: DiscoveryControllerOptions = {}, + ) { + this.#client = client; + this.#orchestrator = orchestrator; + this.#now = options.now ?? (() => Date.now()); + this.#onChange = options.onChange; + } + + get state(): DiscoveryState { + return this.#state; + } + + async connect(onVerified?: () => void): Promise { + await this.verify(); + onVerified?.(); + await this.listRobots(); + return this.#state; + } + + async verify(): Promise { + this.update({ verify: pendingSlot(this.#state.verify) }); + try { + const response = await this.#orchestrator.execute( + { phase: "verifying", operation: "verify" }, + (options) => this.#client.verify(options), + ); + this.update({ verify: succeededSlot(response, this.#now()) }); + return response; + } catch (error: unknown) { + this.update({ verify: failedSlot(error) }); + throw error; + } + } + + async listRobots(): Promise { + if (this.#state.verify.status !== "succeeded") { + throw new Error("Robot discovery requires successful verification."); + } + + this.update({ + robots: pendingSlot(this.#state.robots), + selectedRobot: null, + status: emptySlot(), + locations: emptySlot(), + contacts: emptySlot(), + }); + try { + const response = await this.#orchestrator.execute( + { phase: "discovering", operation: "listRobots" }, + (options) => this.#client.listRobots(options), + ); + const robots = normaliseRobots(response); + this.update({ robots: succeededSlot(robots, this.#now()) }); + return robots; + } catch (error: unknown) { + this.update({ robots: failedSlot(error) }); + throw error; + } + } + + async selectRobot( + serialNumber: string, + phase: RunState = "discovering", + ): Promise { + const robot = this.#state.robots.data?.find( + (candidate) => candidate.serialNumber === serialNumber, + ); + if (robot === undefined) { + throw new RobotSelectionError(); + } + + const version = ++this.#selectionVersion; + this.update({ + selectedRobot: robot, + status: emptySlot(), + locations: emptySlot(), + contacts: emptySlot(), + }); + + const [statusResult, locationsResult, contactsResult] = await Promise.allSettled([ + this.#orchestrator.execute( + { phase, operation: "getRobotStatus", requestSummary: { serialNumber } }, + (options) => this.#client.getRobotStatus(serialNumber, options), + ), + this.#orchestrator.execute( + { phase, operation: "listRobotLocations", requestSummary: { serialNumber } }, + (options) => this.#client.listRobotLocations(serialNumber, options), + ), + this.#orchestrator.execute( + { phase, operation: "listRobotContacts", requestSummary: { serialNumber } }, + (options) => this.#client.listRobotContacts(serialNumber, options), + ), + ]); + + const unauthorized = [statusResult, locationsResult, contactsResult].find( + (result): result is PromiseRejectedResult => + result.status === "rejected" && discoveryFailure(result.reason).kind === "unauthorized", + ); + if (unauthorized !== undefined) { + if (version === this.#selectionVersion) { + this.update({ + status: slotFromResult(statusResult, this.#now()), + locations: slotFromResult(locationsResult, this.#now()), + contacts: slotFromResult(contactsResult, this.#now()), + }); + } + throw unauthorized.reason; + } + + if (version !== this.#selectionVersion) { + return this.#state; + } + + this.update({ + status: slotFromResult(statusResult, this.#now()), + locations: slotFromResult(locationsResult, this.#now()), + contacts: slotFromResult(contactsResult, this.#now()), + }); + return this.#state; + } + + async refreshStatus(phase: RunState = "composing"): Promise { + const robot = this.#state.selectedRobot; + if (robot === null) { + throw new RobotSelectionError(); + } + + const version = this.#selectionVersion; + const previous = this.#state.status; + this.update({ status: pendingSlot(previous) }); + try { + const response = await this.#orchestrator.execute( + { + phase, + operation: "getRobotStatus", + requestSummary: { serialNumber: robot.serialNumber }, + }, + (options) => this.#client.getRobotStatus(robot.serialNumber, options), + ); + if (version !== this.#selectionVersion) return this.#state; + this.update({ status: succeededSlot(response, this.#now()) }); + return this.#state; + } catch (error: unknown) { + if (discoveryFailure(error).kind === "unauthorized") { + throw error; + } + if (version !== this.#selectionVersion) return this.#state; + this.update({ + status: { + status: "failed", + data: previous.data, + failure: discoveryFailure(error), + receivedAt: previous.receivedAt, + }, + }); + throw error; + } + } + + recordStatusSnapshot(response: RobotStatus, receivedAt = this.#now()): void { + const selectedRobot = this.#state.selectedRobot; + if (selectedRobot === null || response.serialNumber !== selectedRobot.serialNumber) return; + this.update({ status: succeededSlot(response, receivedAt) }); + } + + clearSelection(): void { + this.#selectionVersion += 1; + this.update({ + selectedRobot: null, + status: emptySlot(), + locations: emptySlot(), + contacts: emptySlot(), + }); + } + + private update(changes: Partial): void { + this.#state = { ...this.#state, ...changes }; + this.#onChange?.(this.#state); + } +} + +export function initialDiscoveryState(): DiscoveryState { + return { + verify: emptySlot(), + robots: emptySlot(), + selectedRobot: null, + status: emptySlot(), + locations: emptySlot(), + contacts: emptySlot(), + }; +} + +export function discoveryFailure(error: unknown): DiscoveryFailure { + if (error instanceof TemiApiError) { + return { kind: error.kind, status: error.status, code: error.code }; + } + return { kind: "unknown", status: null, code: null }; +} + +export type DiscoveryAction = "MOVEMENT" | "SPEAK" | "START_CALL"; + +export type ActionAvailability = Readonly<{ + enabled: boolean; + reason: string; + requiredScope?: string; +}>; + +export type PlayReadiness = Readonly<{ + ready: boolean; + reason: string; + requiredScope?: string; +}>; + +export function actionAvailability( + state: DiscoveryState, + action: DiscoveryAction, +): ActionAvailability { + if (state.selectedRobot === null) { + return { enabled: false, reason: "Select a robot before preparing an action." }; + } + + if (action === "SPEAK") { + return { enabled: true, reason: "SPEAK does not depend on locations or contacts." }; + } + + const requiredScope = + action === "MOVEMENT" ? "read:robot:locations" : "read:robot:contact"; + const verify = state.verify.data; + if (verify?.robotScope === "selected" && Array.isArray(verify.serialNumbers)) { + if (!verify.serialNumbers.includes(state.selectedRobot.serialNumber)) { + return { + enabled: false, + requiredScope, + reason: "The selected robot is outside this token's robot scope.", + }; + } + } + if (Array.isArray(verify?.scopes) && !verify.scopes.includes(requiredScope)) { + return { + enabled: false, + requiredScope, + reason: `The token does not advertise ${requiredScope}; the server remains the final authorization authority.`, + }; + } + + const slot = action === "MOVEMENT" ? state.locations : state.contacts; + if (slot.status === "pending") { + return { enabled: false, requiredScope, reason: "The required resource is still loading." }; + } + if (slot.status === "failed") { + return { + enabled: false, + requiredScope, + reason: "The required resource could not be read; retry it before using this action.", + }; + } + if (slot.status !== "succeeded") { + return { enabled: false, requiredScope, reason: "The required resource is unavailable." }; + } + + const resourceCount = action === "MOVEMENT" + ? Array.isArray(state.locations.data?.locations) + ? state.locations.data.locations.filter((location) => hasText(location?.name)).length + : 0 + : Array.isArray(state.contacts.data?.contacts) + ? state.contacts.data.contacts.filter((contact) => hasText(contact?.temiId)).length + : 0; + if (resourceCount === 0) { + return { + enabled: false, + requiredScope, + reason: "The robot returned no selectable items for this action.", + }; + } + return { enabled: true, requiredScope, reason: "The required resource is available." }; +} + +export function playReadiness(state: DiscoveryState): PlayReadiness { + if (state.selectedRobot === null) { + return { ready: false, reason: "Select a robot before preparing Play." }; + } + + const requiredScope = "read:robot:status"; + const scopes = state.verify.data?.scopes; + if (Array.isArray(scopes) && !scopes.includes(requiredScope)) { + return { + ready: false, + requiredScope, + reason: `The token does not advertise ${requiredScope}; the server remains the final authorization authority.`, + }; + } + if (state.status.status === "pending") { + return { ready: false, requiredScope, reason: "Status is still loading." }; + } + if (state.status.status === "failed") { + return { ready: false, requiredScope, reason: "Status is unavailable; refresh the last-known snapshot." }; + } + if (state.status.status !== "succeeded" || state.status.data === null) { + return { ready: false, requiredScope, reason: "Status is unknown / unavailable." }; + } + if (state.status.data.status === "online") { + return { ready: true, requiredScope, reason: "Robot is online and can enter Play preparation." }; + } + if (state.status.data.status === "offline") { + return { ready: false, requiredScope, reason: "Play is blocked while the robot is offline; refresh status." }; + } + if (state.status.data.status === "busy") { + return { ready: false, requiredScope, reason: "Play is blocked while the robot is busy; refresh status." }; + } + if (state.status.data.status === "privacy") { + return { ready: false, requiredScope, reason: "Play is blocked while the robot is in privacy; refresh status." }; + } + return { ready: false, requiredScope, reason: "Status is unknown / unavailable; refresh status." }; +} + +function emptySlot(): DiscoverySlot { + return { status: "idle", data: null, failure: null, receivedAt: null }; +} + +function pendingSlot(previous: DiscoverySlot): DiscoverySlot { + return { status: "pending", data: previous.data, failure: null, receivedAt: previous.receivedAt }; +} + +function succeededSlot(data: T, receivedAt: number): DiscoverySlot { + return { status: "succeeded", data, failure: null, receivedAt }; +} + +function failedSlot(error: unknown): DiscoverySlot { + return { status: "failed", data: null, failure: discoveryFailure(error), receivedAt: null }; +} + +function slotFromResult( + result: PromiseSettledResult, + receivedAt: number, +): DiscoverySlot { + return result.status === "fulfilled" + ? succeededSlot(result.value, receivedAt) + : failedSlot(result.reason); +} + +function normaliseRobots(response: RobotsResponse): readonly RobotSummary[] { + if (!Array.isArray(response.robots)) return []; + const seen = new Set(); + return response.robots.flatMap((robot) => { + if ( + typeof robot.serialNumber !== "string" || + robot.serialNumber.trim().length === 0 || + seen.has(robot.serialNumber) + ) { + return []; + } + seen.add(robot.serialNumber); + return [ + { + serialNumber: robot.serialNumber, + ...(typeof robot.teminame === "string" ? { teminame: robot.teminame } : {}), + }, + ]; + }); +} + +function hasText(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/demos/temi-openapi-scenario-runner/src/lifecycle.ts b/demos/temi-openapi-scenario-runner/src/lifecycle.ts new file mode 100644 index 0000000..8eba8ce --- /dev/null +++ b/demos/temi-openapi-scenario-runner/src/lifecycle.ts @@ -0,0 +1,732 @@ +import { + TemiApiError, + type Environment, + type ErrorCode, + type PlaySequenceRequest, + type RobotStatus, + type TemiApiClient, + type TemiApiErrorKind, + type TemiApiRequestOptions, +} from "./api"; +import { RequestOrchestrator } from "./orchestrator"; + +export const POLL_INTERVAL_MS = 2_000; +export const OBSERVATION_TIMEOUT_MS = POLL_INTERVAL_MS * 5; + +export type LifecycleClient = Pick< + TemiApiClient, + "getRobotStatus" | "playSequence" | "stopSequence" | "clearToken" +>; + +export type LifecyclePhase = + | "idle" + | "preflighting" + | "blocked" + | "confirmation_required" + | "starting" + | "running" + | "stopping" + | "terminal" + | "failed" + | "unknown"; + +export type RunObservation = + | "completed" + | "aborted" + | "stopped" + | "observation_unknown" + | "identity_mismatch"; + +export type LifecycleRunIdentity = Readonly<{ + environment: Environment; + serialNumber: string; + sequenceId: string | null; +}>; + +export type LifecycleError = Readonly<{ + kind: TemiApiErrorKind | "unknown"; + status: number | null; + code: ErrorCode | null; +}>; + +export type LifecycleSnapshot = Readonly<{ + phase: LifecyclePhase; + identity: LifecycleRunIdentity | null; + observation: RunObservation | null; + latestStatus: RobotStatus | null; + nextPollAt: number | null; + pollInFlight: boolean; + playPending: boolean; + stopPending: boolean; + error: LifecycleError | null; + warning: string | null; +}>; + +export type PlayPollStopLifecycleOptions = Readonly<{ + client: LifecycleClient; + environment: Environment; + orchestrator: RequestOrchestrator; + now?: () => number; + setTimeout?: (callback: () => void, delayMs: number) => ReturnType; + clearTimeout?: (handle: ReturnType) => void; + onChange?: (snapshot: LifecycleSnapshot) => void; + onStatus?: (status: RobotStatus) => void; + onWarning?: (message: string) => void; +}>; + +export class PlayPollStopLifecycle { + #client: LifecycleClient; + #environment: Environment; + #orchestrator: RequestOrchestrator; + #now: () => number; + #setTimeout: NonNullable; + #clearTimeout: NonNullable; + #onChange: ((snapshot: LifecycleSnapshot) => void) | undefined; + #onStatus: ((status: RobotStatus) => void) | undefined; + #onWarning: ((message: string) => void) | undefined; + #state: LifecycleSnapshot = initialSnapshot(); + #validatedRequest: PlaySequenceRequest | null = null; + #preflightPromise: Promise | null = null; + #playPromise: Promise | null = null; + #pollPromise: Promise | null = null; + #stopPromise: Promise | null = null; + #pollTimer: ReturnType | null = null; + #stopAccepted = false; + #runObserved = false; + #speakProgressObserved = false; + #observationDeadlineAt: number | null = null; + #disposed = false; + + constructor(options: PlayPollStopLifecycleOptions) { + this.#client = options.client; + this.#environment = options.environment; + this.#orchestrator = options.orchestrator; + this.#now = options.now ?? (() => Date.now()); + this.#setTimeout = options.setTimeout ?? ((callback, delayMs) => globalThis.setTimeout(callback, delayMs)); + this.#clearTimeout = options.clearTimeout ?? ((handle) => globalThis.clearTimeout(handle)); + this.#onChange = options.onChange; + this.#onStatus = options.onStatus; + this.#onWarning = options.onWarning; + } + + get state(): LifecycleSnapshot { + return this.#state; + } + + preparePlay(request: PlaySequenceRequest): Promise { + if (this.#preflightPromise !== null) return this.#preflightPromise; + if (!canPrepare(this.#state.phase)) { + throw new Error("Play preparation is not available for the current run."); + } + + const preparedRequest = cloneRequest(request); + this.#validatedRequest = preparedRequest; + this.#stopAccepted = false; + this.#runObserved = false; + this.#speakProgressObserved = false; + this.#observationDeadlineAt = null; + this.#setState({ + phase: "preflighting", + identity: createIdentity(this.#environment, preparedRequest.serialNumber, null), + observation: null, + latestStatus: null, + nextPollAt: null, + pollInFlight: false, + playPending: false, + stopPending: false, + error: null, + warning: null, + }); + + const pending = this.#orchestrator + .execute( + { + phase: "starting", + operation: "getRobotStatus", + requestSummary: { serialNumber: preparedRequest.serialNumber }, + }, + (options) => this.#client.getRobotStatus(preparedRequest.serialNumber, this.#withUnauthorized(options)), + ) + .then((status) => { + this.#onStatus?.(status); + if (status.status === "online") { + this.#setState({ + phase: "confirmation_required", + latestStatus: status, + error: null, + warning: null, + }); + } else { + this.#setState({ + phase: "blocked", + latestStatus: status, + error: null, + warning: null, + }); + } + return this.#state; + }) + .catch((error: unknown) => { + this.#validatedRequest = null; + if (error instanceof TemiApiError && error.kind === "unauthorized") { + this.#notifyUnauthorized(); + } + this.#setState({ + phase: "failed", + identity: null, + latestStatus: null, + nextPollAt: null, + error: lifecycleError(error), + warning: this.#state.warning, + }); + throw error; + }) + .finally(() => { + if (this.#preflightPromise === pending) this.#preflightPromise = null; + }); + + this.#preflightPromise = pending; + return pending; + } + + confirmPlay(): Promise { + if (this.#playPromise !== null) return this.#playPromise; + if (this.#state.phase !== "confirmation_required" || this.#validatedRequest === null) { + throw new Error("Play requires a fresh online preflight and explicit confirmation."); + } + + const request = cloneRequest(this.#validatedRequest); + this.#stopAccepted = false; + this.#runObserved = false; + this.#speakProgressObserved = false; + this.#setState({ + phase: "starting", + playPending: true, + stopPending: false, + observation: null, + nextPollAt: null, + error: null, + warning: null, + }); + + const pending = this.#orchestrator + .execute( + { + phase: "starting", + operation: "playSequence", + requestSummary: request, + }, + (options) => this.#client.playSequence(request, this.#withUnauthorized(options)), + { validateResponse: assertPlaySequenceResponse }, + ) + .then((response) => { + const sequenceId = requiredSequenceId(response); + this.#setState({ + phase: "running", + identity: createIdentity(this.#environment, request.serialNumber, sequenceId), + playPending: false, + stopPending: false, + observation: null, + nextPollAt: this.#now() + POLL_INTERVAL_MS, + error: null, + warning: null, + }); + this.#observationDeadlineAt = this.#now() + OBSERVATION_TIMEOUT_MS; + this.#schedulePoll(); + return this.#state; + }) + .catch((error: unknown) => { + this.#validatedRequest = null; + const unknownResult = isUnknownWriteResult(error); + if (error instanceof TemiApiError && error.kind === "unauthorized") { + this.#notifyUnauthorized(); + } + this.#setState({ + phase: unknownResult ? "unknown" : "failed", + identity: unknownResult + ? this.#state.identity + : null, + playPending: false, + stopPending: false, + observation: unknownResult ? "observation_unknown" : null, + nextPollAt: null, + error: lifecycleError(error), + warning: unknownResult + ? "无法确认 Play 是否已被机器人接受;不会自动重试 Play。" + : this.#state.warning, + }); + throw error; + }) + .finally(() => { + if (this.#playPromise === pending) this.#playPromise = null; + }); + + this.#playPromise = pending; + return pending; + } + + pollNow(): Promise { + if (this.#pollPromise !== null) return this.#pollPromise; + const identity = this.#state.identity; + if ( + identity === null || + identity.sequenceId === null || + !["running", "stopping", "unknown"].includes(this.#state.phase) + ) { + throw new Error("Polling requires an accepted, non-terminal run."); + } + const expectedSequenceId = identity.sequenceId; + + this.#clearPollTimer(); + this.#setState({ pollInFlight: true, nextPollAt: null, error: null }); + const phase = this.#state.phase === "stopping" ? "stopping" : "running"; + let pollEventId: number | null = null; + const pending = this.#orchestrator + .execute( + { + phase, + operation: "getRobotStatus", + requestSummary: { serialNumber: identity.serialNumber }, + }, + (options) => this.#client.getRobotStatus(identity.serialNumber, this.#withUnauthorized(options)), + { onSettled: ({ eventId }) => { pollEventId = eventId; } }, + ) + .then((status) => { + this.#onStatus?.(status); + if (hasMatchingSequence(status, expectedSequenceId)) { + this.#runObserved = true; + this.#observationDeadlineAt = null; + if (hasObservedNearFinalSequenceStep(status, this.#validatedRequest, expectedSequenceId)) { + this.#speakProgressObserved = true; + } + } + const observation = evaluateObservation( + status, + this.#validatedRequest, + expectedSequenceId, + this.#runObserved, + this.#speakProgressObserved, + ); + if (observation.terminal) { + const terminalObservation = this.#stopAccepted && observation.kind !== "identity_mismatch" + ? "stopped" + : observation.kind; + annotatePoll( + this.#orchestrator, + pollEventId, + terminalObservation, + observation.completionInferred === true && terminalObservation === "completed", + ); + this.#setState({ + phase: "terminal", + observation: terminalObservation, + latestStatus: status, + nextPollAt: null, + pollInFlight: false, + stopPending: false, + error: null, + warning: null, + }); + return this.#state; + } + + annotatePoll(this.#orchestrator, pollEventId, observation.kind); + if (this.#observationTimedOut()) { + this.#setState({ + phase: "unknown", + observation: "observation_unknown", + latestStatus: status, + nextPollAt: null, + pollInFlight: false, + error: null, + warning: "未观察到本次 sequence,无法确认运行结果;已停止自动轮询。", + }); + return this.#state; + } + this.#setState({ + phase: this.#state.phase === "stopping" ? "stopping" : "running", + observation: observation.kind, + latestStatus: status, + nextPollAt: this.#now() + POLL_INTERVAL_MS, + pollInFlight: false, + error: null, + }); + this.#schedulePoll(); + return this.#state; + }) + .catch((error: unknown) => { + const unauthorized = error instanceof TemiApiError && error.kind === "unauthorized"; + if (unauthorized) { + this.#notifyUnauthorized(); + } + const observationTimedOut = this.#observationTimedOut(); + annotatePoll(this.#orchestrator, pollEventId, "observation_unknown"); + this.#setState({ + phase: this.#state.phase === "stopping" ? "stopping" : "unknown", + observation: "observation_unknown", + nextPollAt: unauthorized || observationTimedOut ? null : this.#now() + POLL_INTERVAL_MS, + pollInFlight: false, + error: lifecycleError(error), + warning: unauthorized + ? this.#state.warning + : observationTimedOut + ? "未观察到本次 sequence,无法确认运行结果;已停止自动轮询。" + : "本次状态观察未知;只会重试 GET,不会自动重试 Play 或 Stop。", + }); + if (!unauthorized && !observationTimedOut) this.#schedulePoll(); + return this.#state; + }) + .finally(() => { + if (this.#pollPromise === pending) this.#pollPromise = null; + }); + + this.#pollPromise = pending; + return pending; + } + + stop(): Promise { + if (this.#stopPromise !== null) return this.#stopPromise; + const identity = this.#state.identity; + if ( + identity === null || + identity.sequenceId === null || + !["running", "unknown"].includes(this.#state.phase) + ) { + throw new Error("Stop requires the current accepted run to be observable and non-terminal."); + } + + this.#clearPollTimer(); + this.#setState({ + phase: "stopping", + stopPending: true, + nextPollAt: null, + error: null, + warning: null, + }); + this.#observationDeadlineAt = null; + const sequenceId = identity.sequenceId; + const pending = this.#orchestrator + .execute( + { + phase: "stopping", + operation: "stopSequence", + requestSummary: { sequenceId }, + }, + (options) => this.#client.stopSequence({ sequenceId }, this.#withUnauthorized(options)), + ) + .then(() => { + this.#stopAccepted = true; + if (this.#state.phase === "terminal") return this.#state; + this.#setState({ + phase: "stopping", + stopPending: false, + observation: null, + nextPollAt: this.#now() + POLL_INTERVAL_MS, + error: null, + warning: null, + }); + this.#schedulePoll(); + return this.#state; + }) + .catch((error: unknown) => { + if (this.#state.phase === "terminal") return this.#state; + const unauthorized = error instanceof TemiApiError && error.kind === "unauthorized"; + if (unauthorized) { + this.#notifyUnauthorized(); + } + const unknownResult = isUnknownWriteResult(error); + this.#setState({ + phase: unauthorized || unknownResult ? "unknown" : "running", + stopPending: false, + observation: unauthorized || unknownResult ? "observation_unknown" : this.#state.observation, + nextPollAt: unauthorized ? null : this.#now() + POLL_INTERVAL_MS, + error: lifecycleError(error), + warning: unauthorized + ? this.#state.warning + : unknownResult + ? "无法确认 Stop 是否已被接受;不会自动重发 Stop,只会继续 GET 状态观察。" + : null, + }); + if (!unauthorized) this.#schedulePoll(); + throw error; + }) + .finally(() => { + if (this.#stopPromise === pending) this.#stopPromise = null; + }); + + this.#stopPromise = pending; + return pending; + } + + cancelPreparation(): void { + if (!["blocked", "confirmation_required", "failed"].includes(this.#state.phase)) return; + this.#validatedRequest = null; + this.#stopAccepted = false; + this.#runObserved = false; + this.#speakProgressObserved = false; + this.#observationDeadlineAt = null; + this.#clearPollTimer(); + this.#state = initialSnapshot(); + this.#onChange?.(this.#state); + } + + dispose(): void { + this.#disposed = true; + this.#clearPollTimer(); + } + + #setState(changes: Partial): void { + if (this.#disposed) return; + this.#state = Object.freeze({ ...this.#state, ...changes }); + this.#onChange?.(this.#state); + } + + #schedulePoll(): void { + if (this.#disposed) return; + this.#clearPollTimer(); + const dueAt = this.#state.nextPollAt; + if (dueAt === null) return; + this.#pollTimer = this.#setTimeout(() => { + void this.pollNow(); + }, Math.max(0, dueAt - this.#now())); + } + + #clearPollTimer(): void { + if (this.#pollTimer !== null) { + this.#clearTimeout(this.#pollTimer); + this.#pollTimer = null; + } + } + + #observationTimedOut(): boolean { + return !this.#runObserved && + this.#observationDeadlineAt !== null && + this.#now() >= this.#observationDeadlineAt; + } + + #notifyUnauthorized(): void { + const message = "401:已清除内存 OAT;无法确认机器人当前是否仍在执行,不能自动重试 Play 或 Stop。"; + if (this.#state.warning === message) return; + this.#setState({ warning: message }); + this.#onWarning?.(message); + this.#client.clearToken(); + } + + #withUnauthorized(options: TemiApiRequestOptions): TemiApiRequestOptions { + return { + ...options, + onUnauthorized: () => this.#notifyUnauthorized(), + }; + } + +} + +function initialSnapshot(): LifecycleSnapshot { + return Object.freeze({ + phase: "idle", + identity: null, + observation: null, + latestStatus: null, + nextPollAt: null, + pollInFlight: false, + playPending: false, + stopPending: false, + error: null, + warning: null, + }); +} + +function canPrepare(phase: LifecyclePhase): boolean { + return phase === "idle" || phase === "blocked" || phase === "failed"; +} + +function createIdentity( + environment: Environment, + serialNumber: string, + sequenceId: string | null, +): LifecycleRunIdentity { + return Object.freeze({ environment, serialNumber, sequenceId }); +} + +function lifecycleError(error: unknown): LifecycleError { + if (error instanceof TemiApiError) { + return Object.freeze({ kind: error.kind, status: error.status, code: error.code }); + } + return Object.freeze({ kind: "unknown", status: null, code: null }); +} + +function requiredSequenceId(response: { sequenceId?: unknown }): string { + if (typeof response.sequenceId !== "string" || response.sequenceId.trim().length === 0) { + throw new TemiApiError("invalid_response", 202); + } + return response.sequenceId; +} + +function assertPlaySequenceResponse(response: { sequenceId?: unknown }): void { + requiredSequenceId(response); +} + +function isUnknownWriteResult(error: unknown): boolean { + if (!(error instanceof TemiApiError)) return true; + if (error.kind === "network" || error.kind === "timeout" || error.kind === "aborted") return true; + return error.kind === "invalid_response" && (error.status === null || error.status < 400); +} + +type ObservationEvaluation = Readonly<{ + terminal: boolean; + kind: RunObservation; + completionInferred?: boolean; +}>; + +function evaluateObservation( + status: RobotStatus, + request: PlaySequenceRequest | null, + expectedSequenceId: string, + runObserved: boolean, + speakProgressObserved: boolean, +): ObservationEvaluation { + const observedSequenceId = status.sequence?.sequenceId; + if (observedSequenceId !== undefined) { + if (typeof observedSequenceId !== "string" || observedSequenceId.trim().length === 0) { + return { terminal: false, kind: "observation_unknown" }; + } + if (observedSequenceId !== expectedSequenceId) { + return { terminal: false, kind: "identity_mismatch" }; + } + } + + if (!runObserved) return { terminal: false, kind: "observation_unknown" }; + + const sequenceStatus = normaliseStatus(status.sequence?.status); + const callStatus = normaliseStatus(status.call?.status); + const movementActive = isMovementActive(status.movement); + const finalAction = request?.actions.at(-1); + const finalMovement = finalAction?.type === "MOVEMENT" ? finalAction : null; + const sequenceKind = terminalSequenceKind(sequenceStatus); + + if (finalMovement !== null) { + if (sequenceKind === "aborted" || sequenceKind === "stopped") { + return { terminal: true, kind: sequenceKind }; + } + const movement = status.movement; + const atIdle = movement?.type === "idle"; + const atExpectedLocation = + movement?.type === "go_to" && + movement.status === "complete" && + movement.location === finalMovement.location; + if (sequenceStatus === "start") { + return { terminal: false, kind: "observation_unknown" }; + } + if (atIdle || atExpectedLocation) { + return { terminal: true, kind: "completed" }; + } + return { terminal: false, kind: "observation_unknown" }; + } + + const sequenceMissing = status.sequence === undefined || status.sequence === null; + if (finalAction?.type === "SPEAK" && sequenceMissing && speakProgressObserved && !movementActive && callStatus !== "start") { + if (sequenceKind === "aborted" || sequenceKind === "stopped") { + return { terminal: true, kind: sequenceKind }; + } + return { terminal: true, kind: "completed", completionInferred: true }; + } + + if (finalAction?.type === "START_CALL" && sequenceStatus === null && callStatus !== "start") { + return { terminal: true, kind: "completed", completionInferred: true }; + } + + if (sequenceKind !== null) return { terminal: true, kind: sequenceKind }; + return { terminal: false, kind: "observation_unknown" }; +} + +function normaliseStatus(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.toLowerCase() : null; +} + +function isMovementActive(movement: RobotStatus["movement"] | undefined): boolean { + if (movement === undefined || movement.type === "idle") return false; + return normaliseStatus(movement.status) !== "complete"; +} + +function terminalSequenceKind(status: string | null): Exclude | null { + if (status === "abort" || status === "aborted" || status === "cancelled" || status === "canceled" || status === "failed") { + return "aborted"; + } + if (status === "stop" || status === "stopped") return "stopped"; + return null; +} + +function hasMatchingSequence(status: RobotStatus, expectedSequenceId: string): boolean { + return status.sequence?.sequenceId === expectedSequenceId; +} + +function hasObservedNearFinalSequenceStep( + status: RobotStatus, + request: PlaySequenceRequest | null, + expectedSequenceId: string, +): boolean { + const sequence = status.sequence; + const finalStep = request === null ? null : finalSequenceStep(request); + if (sequence === undefined || sequence === null || finalStep === null || !hasMatchingSequence(status, expectedSequenceId)) return false; + + if (!isPositiveInteger(sequence.step)) return false; + const total = isPositiveInteger(sequence.total) ? sequence.total : finalStep; + const nearFinalStep = Math.max(1, total - 1); + return sequence.step >= nearFinalStep && sequence.step >= Math.max(1, finalStep - 1); +} + +function finalSequenceStep(request: PlaySequenceRequest): number | null { + const steps = request.actions + .map((action) => action.endStep ?? action.step ?? action.startStep) + .filter(isPositiveInteger); + return steps.length === 0 ? null : Math.max(...steps); +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1; +} + +function annotatePoll( + orchestrator: RequestOrchestrator, + eventId: number | null, + observation: RunObservation, + completionInferred = false, +): void { + if (eventId === null) return; + orchestrator.timeline.annotate(eventId, { + observation, + explanation: observationExplanation(observation, completionInferred), + }); +} + +function observationExplanation(observation: RunObservation, completionInferred = false): string { + if (observation === "completed") { + return completionInferred + ? "Poll inferred the final action completed after final sequence progress was observed and sequence.status was absent." + : "Poll observed the current sequence completed."; + } + if (observation === "aborted") return "Poll observed the current sequence aborted."; + if (observation === "stopped") return "Poll observed the current sequence stopped after Stop."; + if (observation === "identity_mismatch") { + return "Poll observed an identity mismatch; the different sequence was not treated as this run and was not used for Stop."; + } + return "Poll observation is unknown; only GET status may be retried and no completion was inferred."; +} + +function cloneRequest(request: PlaySequenceRequest): PlaySequenceRequest { + return { + ...request, + actions: request.actions.map((action) => ({ + ...action, + ...(action.contactIds === undefined + ? {} + : { + contactIds: action.contactIds.map((contactId) => + typeof contactId === "string" ? contactId : { ...contactId }, + ), + }), + ...(action.extra === undefined ? {} : { extra: { ...action.extra } }), + })), + ...(request.stopBy === undefined ? {} : { stopBy: { ...request.stopBy } }), + }; +} diff --git a/demos/temi-openapi-scenario-runner/src/main.ts b/demos/temi-openapi-scenario-runner/src/main.ts new file mode 100644 index 0000000..8e191bc --- /dev/null +++ b/demos/temi-openapi-scenario-runner/src/main.ts @@ -0,0 +1,2089 @@ +import logoUrl from "../logo.svg"; +import { + ENVIRONMENTS, + isEnvironment, + TemiApiClient, + TemiApiError, + type Environment, + type RobotSummary, + type RobotStatus, +} from "./api"; +import { + actionAvailability, + discoveryFailure, + RobotDiscoveryController, + RobotSelectionError, + type DiscoveryFailure, + type DiscoveryState, +} from "./discovery"; +import { + SequenceComposer, + SequenceComposerError, + type ComposerContext, + type ComposerOption, + type SequenceActionDraft, + type SequenceComposerState, +} from "./composer"; +import { RequestOrchestrator } from "./orchestrator"; +import { + acceptRun, + beginRun, + createRunState, + hasActiveRun, + recoverUnknown, + requestStop, + transitionRunState, + type RunStateSnapshot, +} from "./run-state"; +import { PlayPollStopLifecycle, type LifecycleSnapshot } from "./lifecycle"; +import { TimelineStore } from "./timeline"; + +const appRoot = document.querySelector("#app"); +if (appRoot === null) throw new Error("Missing demo application root."); +const app: HTMLElement = appRoot; + +const timeline = new TimelineStore(); +const orchestrator = new RequestOrchestrator({ timeline }); + +type DemoScreen = "connect" | "robot" | "actions" | "review" | "running"; +type Language = "zh" | "en"; +type RobotStatusFilter = "all" | "online"; +type EnvironmentTransportState = "unknown" | "readable" | "blocked"; +type StatusTone = "neutral" | "success" | "error"; +type RobotStatusPanelState = { + expanded: boolean; + requestState: "idle" | "pending" | "succeeded" | "failed"; + data: RobotStatus | null; + receivedAt: number | null; + error: string | null; + requestId: number; +}; + +const runtimeEnglish: Readonly> = { + "连接已失效,请重新连接。": "The connection expired. Reconnect and try again.", + "当前环境无法连接,请重新开始。": "This environment cannot be reached. Start over and try again.", + "请输入访问令牌。": "Enter an access token.", + "正在验证连接…": "Verifying the connection…", + "正在读取机器人…": "Loading robots…", + "没有可用的机器人。": "No robots are available.", + "正在读取机器人资源…": "Loading robot resources…", + "部分资源读取失败,可重新读取。": "Some robot resources could not be loaded. You can reload them.", + "部分机器人状态读取失败。": "Some robot statuses could not be loaded.", + "正在刷新状态…": "Refreshing status…", + "状态刷新失败。": "Status refresh failed.", + "当前环境无法连接,不能运行。": "This environment cannot be reached, so the run cannot start.", + "请先检查动作。": "Check the actions first.", + "正在刷新机器人状态…": "Refreshing robot status…", + "正在启动…": "Starting…", + "未能启动运行。": "The run could not be started.", + "正在停止…": "Stopping…", + "停止请求未被确认。": "The stop request was not confirmed.", + "请修正标出的内容。": "Fix the highlighted items.", + "正在检查动作…": "Checking actions…", + "动作检查失败,请修正后重试。": "The action check failed. Fix the actions and try again.", + "运行状态异常。": "The run state is invalid.", + "连接已失效,访问令牌已清除。": "The connection expired and the access token was cleared.", + "所需资源尚不可用。": "The required resources are not available yet.", + "请选择列表中的机器人。": "Select a robot from the list.", + "访问令牌无效或已过期。": "The access token is invalid or expired.", + "连接超时,请检查网络后重试。": "The connection timed out. Check the network and try again.", + "连接失败,请检查网络或浏览器访问限制。": "Connection failed. Check the network or browser access restrictions.", + "状态读取超时。": "The status request timed out.", + "状态读取失败,请检查网络。": "Could not read status. Check the network.", + "无权读取该机器人状态。": "You do not have permission to read this robot's status.", + "状态读取失败。": "Could not read status.", + "请求失败。": "The request failed.", + "连接已失效。": "The connection expired.", + "网络不可用。": "The network is unavailable.", + "请求超时。": "The request timed out.", + "无法确认 Play 是否已被机器人接受;不会自动重试 Play。": "It is unclear whether the robot accepted Play. Play will not be retried automatically.", + "本次状态观察未知;只会重试 GET,不会自动重试 Play 或 Stop。": "This status observation is unknown. Only GET may retry; Play and Stop will not be retried automatically.", + "无法确认 Stop 是否已被接受;不会自动重发 Stop,只会继续 GET 状态观察。": "It is unclear whether Stop was accepted. Stop will not be resent; only GET status observation will continue.", + "401:已清除内存 OAT;无法确认机器人当前是否仍在执行,不能自动重试 Play 或 Stop。": "401: The in-memory OAT was cleared. The robot's current execution state is unknown, so Play and Stop cannot be retried automatically.", +}; + +let environment: Environment = "production"; +let language: Language = "zh"; +let languageMenuOpen = false; +let client: TemiApiClient | null = null; +let discovery: RobotDiscoveryController | null = null; +let composer: SequenceComposer | null = null; +let runState: RunStateSnapshot = createRunState(); +let lifecycle: PlayPollStopLifecycle | null = null; +let environmentTransport: Record = { + production: "unknown", + integration: "unknown", +}; + +let pendingRobotSerialNumber: string | null = null; +const robotStatusPanels = new Map(); +let nextRobotStatusRequestId = 1; +let robotStatusFilter: RobotStatusFilter = "all"; +let robotStatusesRefreshed = false; +let robotStatusesRefreshing = false; +let robotResourcesLoading = false; +let editingValidatedSequence = false; +let addActionMenuOpen = false; +let actionMenuId: string | null = null; +let draggedActionId: string | null = null; +let statusMessage = ""; +let statusTone: StatusTone = "neutral"; + +function render(): void { + document.documentElement.lang = language === "zh" ? "zh-CN" : "en"; + document.title = copy("场景运行 | temi Developer", "Scenario Runner | temi Developer"); + app.innerHTML = renderPage(); + bindEvents(); +} + +function renderPage(): string { + const screen = currentScreen(); + return ` +
+ ${renderHeader()} +
+ ${screen === "connect" ? "" : renderProgress(screen)} + ${renderScreen(screen)} +
+ ${renderPlayConfirmation()} +
+ `; +} + +function currentScreen(): DemoScreen { + if (client === null || runState.phase === "idle" || runState.phase === "verifying") { + return "connect"; + } + if (discovery === null || discovery.state.selectedRobot === null) return "robot"; + + const lifecyclePhase = lifecycle?.state.phase; + const hasRunView = lifecycle !== null && ( + ["starting", "running", "stopping", "terminal", "unknown"].includes(lifecyclePhase ?? "") || + ["starting", "running", "stopping", "terminal", "unknown", "failed"].includes(runState.phase) + ); + if (hasRunView) return "running"; + if (editingValidatedSequence) return "actions"; + if ( + lifecyclePhase === "preflighting" || + lifecyclePhase === "blocked" || + lifecyclePhase === "confirmation_required" || + lifecyclePhase === "failed" + ) { + return "review"; + } + if ( + composer?.state.validation.status === "succeeded" && + (runState.phase === "ready" || runState.phase === "terminal") + ) { + return "review"; + } + return "actions"; +} + +function renderHeader(): string { + const connected = client !== null; + const verified = discovery?.state.verify.status === "succeeded"; + const statusText = !connected + ? copy("未连接", "Not connected") + : verified + ? copy(`${environmentLabel(environment)} · 已连接`, `${environmentLabel(environment)} · Connected`) + : copy("正在连接", "Connecting"); + const canDisconnect = connected && !isLifecycleLocked() && runState.phase !== "validating"; + const languageLabel = language === "zh" ? "中文" : "English"; + + return ` +
+
+
+ temi developer +
+
+
+ + ${languageMenuOpen ? renderLanguageMenu() : ""} +
+ + + ${statusText} + + ${canDisconnect ? `` : ""} +
+
+
+ `; +} + +function renderLanguageMenu(): string { + return ` + + `; +} + +function renderProgress(screen: DemoScreen): string { + const steps = [ + copy("选择机器人", "Select robot"), + copy("编排动作", "Add actions"), + copy("确认运行", "Review"), + ]; + const currentIndex = screen === "robot" ? 0 : screen === "actions" ? 1 : screen === "review" ? 2 : 3; + return ` + + `; +} + +function renderScreen(screen: DemoScreen): string { + if (screen === "connect") return renderConnect(); + if (screen === "robot") return renderRobotSelection(); + if (screen === "actions") return renderActions(); + if (screen === "review") return renderReview(); + return renderRunning(); +} + +function renderConnect(): string { + const connecting = runState.phase === "verifying"; + const failed = runState.phase === "failed"; + const blocked = environmentTransport[environment] === "blocked"; + const canConnect = runState.phase === "idle" && client === null && !blocked; + return ` +
+
+
+
+ + +
+
+ + +

${copy("仅在本页使用,连接后清空。", "Used only on this page and cleared after connecting.")}

+
+ + ${renderStatusNotice()} +
+ ${failed || blocked ? `` : ""} +
+
+ `; +} + +function renderRobotSelection(): string { + const state = discovery?.state; + const allRobots = state?.robots.data ?? []; + const orderedRobots = robotStatusesRefreshed ? sortRobotsOnlineFirst(allRobots) : allRobots; + const robots = robotStatusFilter === "online" + ? orderedRobots.filter((robot) => isRobotOnline(robot.serialNumber)) + : orderedRobots; + const onlineCount = allRobots.filter((robot) => isRobotOnline(robot.serialNumber)).length; + const loading = state?.robots.status === "pending" || runState.phase === "discovering" && state?.robots.status !== "succeeded"; + const failed = state?.robots.status === "failed" || runState.phase === "failed"; + + return ` +
+
+
+

${copy("选择机器人", "Select a robot")}

+

${copy("选择这次运行要使用的机器人。", "Choose the robot for this run.")}

+
+ ${allRobots.length > 0 ? ` +
+ + + ${copy(`${allRobots.length} 台设备`, `${allRobots.length} ${allRobots.length === 1 ? "device" : "devices"}`)} +
+ ` : ""} +
+ ${loading ? renderLoadingPanel(copy("正在读取机器人…", "Loading robots…")) : ""} + ${failed ? renderInlineNotice(copy("机器人列表读取失败,请重新连接。", "Could not load the robot list. Reconnect and try again."), "error") : ""} + ${!loading && !failed && allRobots.length === 0 ? renderInlineNotice(copy("没有可用的机器人。", "No robots are available."), "error") : ""} + ${!loading && !failed && allRobots.length > 0 && robots.length === 0 ? renderInlineNotice(copy("当前没有在线机器人。", "No robots are currently online."), "neutral") : ""} + ${robots.length === 0 ? "" : ` +
+ ${robots.map((robot, index) => { + const selected = pendingRobotSerialNumber === robot.serialNumber; + const statusPanel = robotStatusPanels.get(robot.serialNumber); + const expanded = statusPanel?.expanded === true; + const panelId = `robot-status-${index}`; + return ` +
+ + + ${expanded ? renderRobotStatusPanel(panelId, statusPanel) : ""} +
+ `; + }).join("")} +
+ `} + ${renderStatusNotice()} +
+ ${failed ? `` : ""} + +
+
+ `; +} + +function renderRobotCardStatus(panel: RobotStatusPanelState | undefined): string { + if (panel?.requestState === "pending") { + return `${copy("刷新中…", "Refreshing…")}`; + } + if (panel?.requestState === "failed") { + return `${copy("读取失败", "Unavailable")}`; + } + if (panel?.requestState === "succeeded" && panel.data !== null) { + return `${statusLabel(panel.data.status)}`; + } + return `${copy("未刷新", "Not refreshed")}`; +} + +function sortRobotsOnlineFirst(robots: readonly RobotSummary[]): readonly RobotSummary[] { + return [...robots].sort((left, right) => Number(isRobotOnline(right.serialNumber)) - Number(isRobotOnline(left.serialNumber))); +} + +function isRobotOnline(serialNumber: string): boolean { + const panel = robotStatusPanels.get(serialNumber); + return panel?.data?.status === "online" && panel.requestState !== "failed"; +} + +function renderRobotStatusPanel( + panelId: string, + panel: RobotStatusPanelState | undefined, +): string { + if (panel === undefined || panel.requestState === "idle") return ""; + if (panel.requestState === "pending" && panel.data === null) { + return ` +
+

${copy("正在读取状态…", "Loading status…")}

+
+ `; + } + if (panel.requestState === "failed" || panel.data === null) { + return ` +
+

${escapeHtml(localizeRuntimeText(panel.error ?? "状态读取失败。"))}

+
+ `; + } + + const status = panel.data; + return ` +
+ ${panel.requestState === "pending" ? `

${copy("正在更新…", "Updating…")}

` : ""} +
+
${copy("状态", "Status")}
${statusLabel(status.status)}
+
${copy("电量", "Battery")}
${escapeHtml(robotBatteryText(status))}
+
${copy("移动", "Movement")}
${escapeHtml(robotMovementText(status))}
+
${copy("任务", "Sequence")}
${escapeHtml(robotSequenceText(status))}
+
${copy("通话", "Call")}
${escapeHtml(robotCallText(status))}
+
+

${copy("最近一次读取结果", "Latest reading")}${panel.receivedAt === null ? "" : ` · ${formatClock(panel.receivedAt)}`}

+
+ `; +} + +function renderActions(): string { + const discoveryState = discovery?.state; + const composerState = composer?.state; + const selectedRobot = discoveryState?.selectedRobot; + if (discoveryState === undefined || selectedRobot === null || selectedRobot === undefined || composerState === undefined) { + return renderLoadingPanel(copy("正在准备动作编辑器…", "Preparing the action editor…")); + } + + const actions = composerState.actions; + const loadingResources = robotResourcesLoading || runState.phase === "discovering"; + const editLocked = loadingResources || runState.phase === "validating" || isLifecycleLocked(); + const validationIssues = composerState.validation.status === "failed" ? composerState.validation.issues.length : 0; + const hasResourceFailures = [discoveryState.status, discoveryState.locations, discoveryState.contacts] + .some((slot) => slot.status === "failed"); + + return ` +
+
+
+

${copy("编排动作", "Add actions")}

+

${copy("动作会按照这里的顺序执行。", "Actions run in the order shown here.")}

+
+ ${copy(`${actions.length} 个动作`, `${actions.length} ${actions.length === 1 ? "action" : "actions"}`)} +
+ ${renderRobotContext(discoveryState)} + ${loadingResources ? renderInlineNotice(copy("正在读取机器人资源…", "Loading robot resources…"), "neutral") : renderResourceNotice(discoveryState)} + ${loadingResources || hasResourceFailures ? "" : renderStatusNotice()} + ${actions.length === 0 + ? `
${copy("还没有动作", "No actions yet")}${copy("从移动、播报或呼叫开始。", "Start with movement, speech, or a call.")}
` + : `
    ${actions.map((action, index) => renderActionCard(action, index, composerState, editLocked)).join("")}
`} + ${renderComposerValidation(composerState)} +
+ + ${addActionMenuOpen ? renderAddActionMenu(discoveryState, editLocked) : ""} +
+
+ + +
+
+ `; +} + +function renderRobotContext(state: DiscoveryState): string { + const robot = state.selectedRobot; + if (robot === null) return ""; + const status = state.status.data?.status; + const battery = state.status.data?.battery?.level; + const updatedAt = state.status.receivedAt; + const meta = [ + robot.serialNumber, + typeof battery === "number" && Number.isFinite(battery) ? copy(`${battery}% 电量`, `${battery}% battery`) : null, + updatedAt === null ? null : copy(`更新于 ${formatClock(updatedAt)}`, `Updated at ${formatClock(updatedAt)}`), + ].filter((item): item is string => item !== null).join(" · "); + + return ` +
+
+ +
+ ${displayName(robot.teminame, copy("未命名机器人", "Unnamed robot"))} +

${escapeHtml(meta)}

+
+
+
+ ${renderStatusPill(status, robotResourcesLoading || state.status.status === "pending")} + +
+
+ `; +} + +function renderResourceNotice(state: DiscoveryState): string { + const failures: string[] = []; + if (state.status.status === "failed") failures.push(copy("状态读取失败", "Status unavailable")); + if (state.locations.status === "failed") failures.push(copy("位置不可用", "Locations unavailable")); + if (state.contacts.status === "failed") failures.push(copy("联系人不可用", "Contacts unavailable")); + if (failures.length > 0) { + return ` +
+ ${renderInlineNotice(`${failures.join(copy(",", ", "))}${copy("。", ".")}`, "error")} + +
+ `; + } + return ""; +} + +function renderAddActionMenu(state: DiscoveryState, locked: boolean): string { + const actions = [ + { type: "MOVEMENT", glyph: "M", label: copy("前往位置", "Go to location") }, + { type: "SPEAK", glyph: "S", label: copy("播报文字", "Speak text") }, + { type: "START_CALL", glyph: "C", label: copy("发起呼叫", "Start call") }, + ] as const; + return ` + + `; +} + +function renderActionCard( + action: SequenceActionDraft, + index: number, + state: SequenceComposerState, + locked: boolean, +): string { + return ` +
  • +
    + +
    +
    + ${index + 1} +

    ${actionLabel(action.type)}

    +
    + ${renderActionFields(action, state, locked)} +
    +
    + + ${actionMenuId === action.id ? renderActionMenu(action, index, state.actions.length) : ""} +
    +
    +
  • + `; +} + +function renderActionFields( + action: SequenceActionDraft, + state: SequenceComposerState, + locked: boolean, +): string { + const error = actionValidationIssue(state, action.id); + const context = state.context; + if (action.type === "MOVEMENT") { + return ` +
    + + + ${error === null ? "" : `

    ${escapeHtml(error)}

    `} +
    + `; + } + if (action.type === "SPEAK") { + return ` +
    + + + ${error === null ? "" : `

    ${escapeHtml(error)}

    `} +
    + `; + } + return ` +
    + ${copy("联系人", "Contacts")} +
    + ${(context?.contacts ?? []).map((option, optionIndex) => ` + + `).join("")} +
    + ${error === null ? "" : `

    ${escapeHtml(error)}

    `} +
    + `; +} + +function renderActionMenu(action: SequenceActionDraft, index: number, total: number): string { + return ` + + `; +} + +function renderComposerValidation(state: SequenceComposerState): string { + if (state.validation.status === "pending") return renderInlineNotice(copy("正在检查动作…", "Checking actions…"), "neutral"); + if (state.validation.status !== "failed") return ""; + const sequenceIssues = state.validation.issues.filter((issue) => issue.actionId === null); + const remoteError = state.validation.error; + if (sequenceIssues.length === 0 && remoteError === null) return ""; + const message = remoteError === null + ? sequenceIssues.map((issue) => validationIssueText(issue.field)).join(" ") + : validationErrorText(remoteError); + return renderInlineNotice(message, "error"); +} + +function renderReview(): string { + const discoveryState = discovery?.state; + const composerState = composer?.state; + const robot = discoveryState?.selectedRobot; + if (discoveryState === undefined || composerState === undefined || robot === null || robot === undefined) { + return renderLoadingPanel(copy("正在准备确认内容…", "Preparing the review…")); + } + + const snapshot = lifecycle?.state; + const preflighting = snapshot?.phase === "preflighting"; + const confirmationRequired = snapshot?.phase === "confirmation_required"; + const blocked = snapshot?.phase === "blocked"; + const preflightFailed = snapshot?.phase === "failed"; + const status = snapshot?.latestStatus?.status ?? discoveryState.status.data?.status; + const primaryLabel = preflighting + ? copy("正在检查状态…", "Checking status…") + : blocked || preflightFailed + ? copy("重新检查", "Check again") + : copy("确认运行", "Confirm run"); + + return ` +
    +
    +
    +

    ${copy("确认运行", "Review run")}

    +

    ${copy("检查机器人和动作顺序。", "Review the robot and action order.")}

    +
    +
    +
    +
    +
    + ${displayName(robot.teminame, copy("未命名机器人", "Unnamed robot"))} +

    ${escapeHtml(robot.serialNumber)}${discoveryState.status.receivedAt === null ? "" : copy(` · 状态更新于 ${formatClock(discoveryState.status.receivedAt)}`, ` · Status updated at ${formatClock(discoveryState.status.receivedAt)}`)}

    +
    + ${renderStatusPill(status, preflighting)} +
    +
      + ${composerState.actions.map((action, index) => ` +
    1. + ${index + 1} +
      ${actionLabel(action.type)}

      ${escapeHtml(actionSummary(action, composerState.context))}

      + ${copy("已检查", "Checked")} +
    2. + `).join("")} +
    +
    + ${renderReviewCheck(snapshot, status)} + ${statusTone === "error" && !blocked && !preflightFailed ? renderStatusNotice() : ""} +
    + + +
    +
    + `; +} + +function renderReviewCheck(snapshot: LifecycleSnapshot | undefined, status: RobotStatus["status"] | undefined): string { + if (snapshot?.phase === "preflighting") { + return `
    ${copy("正在刷新机器人状态…", "Refreshing robot status…")}
    `; + } + if (snapshot?.phase === "blocked") { + return `
    !${copy(`机器人当前${statusLabelFor(status, "zh")},暂时无法运行。`, `The robot is currently ${statusLabelFor(status, "en")} and cannot run yet.`)}
    `; + } + if (snapshot?.phase === "failed") { + return `
    !${copy("状态检查失败,请重试。", "The status check failed. Try again.")}
    `; + } + return `
    ✓${copy("动作已检查,运行前会再次确认机器人在线。", "Actions are checked. Robot availability will be confirmed again before running.")}
    `; +} + +function renderPlayConfirmation(): string { + const snapshot = lifecycle?.state; + const composerState = composer?.state; + const robot = discovery?.state.selectedRobot; + if (snapshot?.phase !== "confirmation_required" || composerState === undefined || robot === null || robot === undefined) { + return ""; + } + const hasSafetySensitiveAction = composerState.actions.some((action) => action.type === "MOVEMENT" || action.type === "START_CALL"); + const actionOrder = composerState.actions.map((action) => actionLabel(action.type)).join(" → "); + return ` + + `; +} + +function renderRunning(): string { + const snapshot = lifecycle?.state; + const composerState = composer?.state; + const robot = discovery?.state.selectedRobot; + if (snapshot === undefined || composerState === undefined || robot === null || robot === undefined) { + return renderLoadingPanel(copy("正在读取运行状态…", "Loading run status…")); + } + + const title = runningTitle(snapshot); + const subtitle = runningSubtitle(snapshot); + const canStop = snapshot.identity !== null && snapshot.identity.sequenceId !== null && + ["running", "unknown"].includes(snapshot.phase) && !snapshot.stopPending; + const terminal = snapshot.phase === "terminal"; + const failed = snapshot.phase === "failed"; + + return ` +
    +
    +
    +

    ${title}

    +

    ${subtitle}

    +
    + ${renderRunBadge(snapshot)} +
    +
    +
    +
    + ${displayName(robot.teminame, copy("未命名机器人", "Unnamed robot"))} +

    ${escapeHtml(robot.serialNumber)}

    +
    + ${renderStatusPill(snapshot.latestStatus?.status ?? discovery?.state.status.data?.status, snapshot.pollInFlight)} +
    +
      + ${composerState.actions.map((action, index) => renderRunItem(action, index, composerState.context, snapshot)).join("")} +
    +
    + ${snapshot.warning === null ? "" : renderInlineNotice(localizeRuntimeText(snapshot.warning), "error")} + ${snapshot.error === null || snapshot.warning !== null ? "" : renderInlineNotice(lifecycleErrorText(snapshot), "error")} + ${statusTone === "error" && snapshot.warning === null && snapshot.error === null ? renderStatusNotice() : ""} +
    + + ${canStop + ? `` + : terminal + ? `` + : failed + ? `` + : ""} +
    +
    + `; +} + +function renderRunItem( + action: SequenceActionDraft, + index: number, + context: ComposerContext | null, + snapshot: LifecycleSnapshot, +): string { + const submitted = ["running", "stopping", "unknown", "terminal"].includes(snapshot.phase); + const status = snapshot.phase === "terminal" + ? copy("序列已结束", "Sequence ended") + : submitted + ? copy("已提交", "Submitted") + : copy("等待中", "Waiting"); + const className = submitted ? "active" : "waiting"; + const marker = String(index + 1); + return ` +
  • + ${marker} +
    ${actionLabel(action.type)}

    ${escapeHtml(actionSummary(action, context))}

    + ${status} +
  • + `; +} + +function renderRunBadge(snapshot: LifecycleSnapshot): string { + if (snapshot.phase === "running") return `${copy("运行中", "Running")}`; + const success = snapshot.phase === "terminal" && snapshot.observation === "completed"; + return `${lifecyclePhaseLabel(snapshot.phase)}`; +} + +function renderStatusPill(status: RobotStatus["status"] | undefined, loading = false): string { + if (loading) return `${copy("读取中", "Loading")}`; + const className = status === "online" ? "online" : status === "busy" || status === "privacy" ? "busy" : ""; + return `${statusLabel(status)}`; +} + +function renderLoadingPanel(message: string): string { + return `
    ${escapeHtml(message)}
    `; +} + +function renderStatusNotice(): string { + if (statusMessage.trim().length === 0) return ""; + return renderInlineNotice(localizeRuntimeText(statusMessage), statusTone); +} + +function renderInlineNotice(message: string, tone: StatusTone): string { + return `

    ${escapeHtml(message)}

    `; +} + +function bindEvents(): void { + app.querySelector("[data-language-toggle]")?.addEventListener("click", () => { + languageMenuOpen = !languageMenuOpen; + render(); + }); + + app.querySelectorAll("[data-language]").forEach((button) => { + button.addEventListener("click", () => { + const nextLanguage = button.dataset.language; + if (!isLanguage(nextLanguage)) return; + language = nextLanguage; + languageMenuOpen = false; + render(); + }); + }); + + app.querySelector("[data-environment]")?.addEventListener("change", (event) => { + const value = (event.currentTarget as HTMLSelectElement).value; + if (!isEnvironment(value)) return; + environment = value; + resetPage(""); + }); + + app.querySelector("[data-connection-form]")?.addEventListener("submit", (event) => { + event.preventDefault(); + connectAndVerify(); + }); + + app.querySelectorAll("[data-robot-card]").forEach((button) => { + button.addEventListener("click", () => { + pendingRobotSerialNumber = button.dataset.robotCard ?? null; + statusMessage = ""; + render(); + }); + }); + + app.querySelectorAll("[data-robot-status-toggle]").forEach((button) => { + button.addEventListener("click", () => { + const serialNumber = button.dataset.robotStatusToggle; + if (serialNumber !== undefined) toggleRobotStatus(serialNumber); + }); + }); + + app.querySelector("[data-filter-online]")?.addEventListener("click", () => { + if (!robotStatusesRefreshed) return; + robotStatusFilter = robotStatusFilter === "online" ? "all" : "online"; + if ( + robotStatusFilter === "online" && + pendingRobotSerialNumber !== null && + !isRobotOnline(pendingRobotSerialNumber) + ) { + pendingRobotSerialNumber = null; + } + render(); + }); + + app.querySelector("[data-refresh-all-statuses]")?.addEventListener("click", () => { + void refreshAllRobotStatuses(); + }); + + app.querySelector("[data-continue-robot]")?.addEventListener("click", () => { + if (pendingRobotSerialNumber !== null) selectRobot(pendingRobotSerialNumber); + }); + + app.querySelector("[data-back-robot]")?.addEventListener("click", () => { + const selectedSerial = discovery?.state.selectedRobot?.serialNumber ?? null; + if (selectedSerial !== null) pendingRobotSerialNumber = selectedSerial; + selectRobot(""); + }); + + app.querySelector("[data-refresh-status]")?.addEventListener("click", refreshStatus); + app.querySelector("[data-refresh-resources]")?.addEventListener("click", refreshRobotResources); + + app.querySelector("[data-toggle-add-menu]")?.addEventListener("click", () => { + addActionMenuOpen = !addActionMenuOpen; + actionMenuId = null; + render(); + }); + + app.querySelectorAll("[data-add-action]").forEach((button) => { + button.addEventListener("click", () => { + const type = button.dataset.addAction; + if (!isSequenceActionType(type) || composer === null) return; + try { + composer.addAction(type); + addActionMenuOpen = false; + statusMessage = ""; + render(); + } catch (error: unknown) { + handleComposerError(error, "添加动作"); + } + }); + }); + + app.querySelectorAll("[data-toggle-action-menu]").forEach((button) => { + button.addEventListener("click", () => { + const actionId = button.dataset.toggleActionMenu ?? null; + actionMenuId = actionMenuId === actionId ? null : actionId; + addActionMenuOpen = false; + render(); + }); + }); + + app.querySelectorAll("[data-remove-action]").forEach((button) => { + button.addEventListener("click", () => { + const actionId = button.dataset.actionId; + if (composer === null || actionId === undefined) return; + try { + composer.removeAction(actionId); + actionMenuId = null; + statusMessage = ""; + render(); + } catch (error: unknown) { + handleComposerError(error, "删除动作"); + } + }); + }); + + app.querySelectorAll("[data-move-action]").forEach((button) => { + button.addEventListener("click", () => { + const actionId = button.dataset.actionId; + const direction = button.dataset.moveAction; + if (composer === null || actionId === undefined || !isMoveDirection(direction)) return; + try { + composer.moveAction(actionId, direction); + actionMenuId = null; + statusMessage = ""; + render(); + } catch (error: unknown) { + handleComposerError(error, "调整顺序"); + } + }); + }); + + bindDragAndDrop(); + + app.querySelectorAll('[data-action-field="location"]').forEach((select) => { + select.addEventListener("change", () => { + const actionId = select.dataset.actionId; + if (composer === null || actionId === undefined) return; + try { + statusMessage = ""; + composer.setMovementLocation(actionId, select.value); + } catch (error: unknown) { + handleComposerError(error, "修改位置"); + } + }); + }); + + app.querySelectorAll('[data-action-field="tts"]').forEach((textarea) => { + textarea.addEventListener("change", () => { + const actionId = textarea.dataset.actionId; + if (composer === null || actionId === undefined) return; + try { + statusMessage = ""; + composer.setSpeakText(actionId, textarea.value); + } catch (error: unknown) { + handleComposerError(error, "修改播报"); + } + }); + }); + + app.querySelectorAll("[data-action-contact]").forEach((checkbox) => { + checkbox.addEventListener("change", () => { + const actionId = checkbox.dataset.actionId; + if (composer === null || actionId === undefined) return; + const contactIds = Array.from(app.querySelectorAll("[data-action-contact]")) + .filter((candidate) => candidate.dataset.actionId === actionId && candidate.checked) + .map((candidate) => candidate.value); + try { + statusMessage = ""; + composer.setCallContacts(actionId, contactIds); + } catch (error: unknown) { + handleComposerError(error, "修改联系人"); + } + }); + }); + + app.querySelector("[data-validate-sequence]")?.addEventListener("click", validateSequence); + + app.querySelector("[data-back-actions]")?.addEventListener("click", () => { + lifecycle?.cancelPreparation(); + lifecycle?.dispose(); + lifecycle = null; + editingValidatedSequence = true; + statusMessage = ""; + render(); + }); + + app.querySelector("[data-prepare-play]")?.addEventListener("click", preparePlay); + + app.querySelector("[data-cancel-play]")?.addEventListener("click", () => { + lifecycle?.cancelPreparation(); + lifecycle?.dispose(); + lifecycle = null; + editingValidatedSequence = true; + statusMessage = ""; + render(); + }); + + app.querySelector("[data-confirm-play]")?.addEventListener("click", confirmPlay); + app.querySelector("[data-stop-run]")?.addEventListener("click", stopRun); + + app.querySelector("[data-run-again]")?.addEventListener("click", () => { + lifecycle?.dispose(); + lifecycle = null; + editingValidatedSequence = false; + statusMessage = ""; + render(); + }); + + app.querySelectorAll("[data-reset]").forEach((button) => { + button.addEventListener("click", () => resetPage("")); + }); +} + +function bindDragAndDrop(): void { + app.querySelectorAll("[data-action-card]").forEach((card) => { + card.addEventListener("dragstart", (event) => { + draggedActionId = card.dataset.actionId ?? null; + card.classList.add("dragging"); + if (event.dataTransfer !== null) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", draggedActionId ?? ""); + } + }); + card.addEventListener("dragend", () => { + draggedActionId = null; + card.classList.remove("dragging"); + }); + card.addEventListener("dragover", (event) => { + if (draggedActionId === null) return; + event.preventDefault(); + if (event.dataTransfer !== null) event.dataTransfer.dropEffect = "move"; + }); + card.addEventListener("drop", (event) => { + event.preventDefault(); + const targetId = card.dataset.actionId; + if (composer === null || draggedActionId === null || targetId === undefined || draggedActionId === targetId) return; + moveActionTo(draggedActionId, targetId); + draggedActionId = null; + }); + }); +} + +function moveActionTo(actionId: string, targetId: string): void { + const currentComposer = composer; + if (currentComposer === null) return; + try { + let from = currentComposer.state.actions.findIndex((action) => action.id === actionId); + const to = currentComposer.state.actions.findIndex((action) => action.id === targetId); + if (from < 0 || to < 0) return; + while (from < to) { + currentComposer.moveAction(actionId, "down"); + from += 1; + } + while (from > to) { + currentComposer.moveAction(actionId, "up"); + from -= 1; + } + actionMenuId = null; + statusMessage = ""; + render(); + } catch (error: unknown) { + handleComposerError(error, "调整顺序"); + } +} + +function toggleRobotStatus(serialNumber: string): void { + const requestClient = client; + const requestDiscovery = discovery; + const knownRobot = requestDiscovery?.state.robots.data?.some( + (robot) => robot.serialNumber === serialNumber, + ) === true; + if (requestClient === null || requestDiscovery === null || !knownRobot) return; + + const current = robotStatusPanels.get(serialNumber) ?? { + expanded: false, + requestState: "idle", + data: null, + receivedAt: null, + error: null, + requestId: 0, + } satisfies RobotStatusPanelState; + + if (current.expanded) { + robotStatusPanels.set(serialNumber, { ...current, expanded: false }); + render(); + return; + } + if (current.requestState === "pending") { + robotStatusPanels.set(serialNumber, { ...current, expanded: true }); + render(); + return; + } + + const requestId = nextRobotStatusRequestId++; + robotStatusPanels.set(serialNumber, { + ...current, + expanded: true, + requestState: "pending", + error: null, + requestId, + }); + render(); + + void orchestrator.execute( + { + phase: runState.phase, + operation: "getRobotStatus", + requestSummary: { serialNumber }, + }, + (options) => requestClient.getRobotStatus(serialNumber, options), + ).then((status) => { + if (client !== requestClient || discovery !== requestDiscovery) return; + const latest = robotStatusPanels.get(serialNumber); + if (latest?.requestId !== requestId) return; + robotStatusPanels.set(serialNumber, { + ...latest, + requestState: "succeeded", + data: status, + receivedAt: Date.now(), + error: null, + }); + render(); + }).catch((error: unknown) => { + if (client !== requestClient || discovery !== requestDiscovery) return; + const latest = robotStatusPanels.get(serialNumber); + if (latest?.requestId !== requestId) return; + if (error instanceof TemiApiError && error.kind === "unauthorized") { + expireRobotStatusConnection(requestClient, requestDiscovery); + return; + } + robotStatusPanels.set(serialNumber, { + ...latest, + requestState: "failed", + error: robotStatusErrorText(error), + }); + render(); + }); +} + +async function refreshAllRobotStatuses(): Promise { + const requestClient = client; + const requestDiscovery = discovery; + const robots = requestDiscovery?.state.robots.data ?? []; + if ( + requestClient === null || + requestDiscovery === null || + robots.length === 0 || + robotStatusesRefreshing + ) { + return; + } + + robotStatusesRefreshing = true; + statusMessage = ""; + const phase = runState.phase; + const requests = robots.map((robot) => { + const current = robotStatusPanels.get(robot.serialNumber) ?? { + expanded: false, + requestState: "idle", + data: null, + receivedAt: null, + error: null, + requestId: 0, + } satisfies RobotStatusPanelState; + const requestId = nextRobotStatusRequestId++; + robotStatusPanels.set(robot.serialNumber, { + ...current, + requestState: "pending", + error: null, + requestId, + }); + + return orchestrator.execute( + { + phase, + operation: "getRobotStatus", + requestSummary: { serialNumber: robot.serialNumber }, + }, + (options) => requestClient.getRobotStatus(robot.serialNumber, options), + ).then((status) => { + if (client !== requestClient || discovery !== requestDiscovery) return; + const latest = robotStatusPanels.get(robot.serialNumber); + if (latest?.requestId !== requestId) return; + robotStatusPanels.set(robot.serialNumber, { + ...latest, + requestState: "succeeded", + data: status, + receivedAt: Date.now(), + error: null, + }); + }).catch((error: unknown) => { + if (client !== requestClient || discovery !== requestDiscovery) return; + const latest = robotStatusPanels.get(robot.serialNumber); + if (latest?.requestId !== requestId) return; + if (error instanceof TemiApiError && error.kind === "unauthorized") { + expireRobotStatusConnection(requestClient, requestDiscovery); + return; + } + robotStatusPanels.set(robot.serialNumber, { + ...latest, + requestState: "failed", + error: robotStatusErrorText(error), + }); + }); + }); + + render(); + await Promise.all(requests); + if (client !== requestClient || discovery !== requestDiscovery) return; + + robotStatusesRefreshing = false; + robotStatusesRefreshed = true; + if ( + robotStatusFilter === "online" && + pendingRobotSerialNumber !== null && + !isRobotOnline(pendingRobotSerialNumber) + ) { + pendingRobotSerialNumber = null; + } + const failed = robots.some((robot) => robotStatusPanels.get(robot.serialNumber)?.requestState === "failed"); + statusMessage = failed ? "部分机器人状态读取失败。" : ""; + statusTone = failed ? "error" : "success"; + render(); +} + +function expireRobotStatusConnection( + requestClient: TemiApiClient, + requestDiscovery: RobotDiscoveryController, +): void { + if (client !== requestClient || discovery !== requestDiscovery) return; + orchestrator.abortReads(); + lifecycle?.dispose(); + lifecycle = null; + discovery = null; + composer = null; + clearConnection(); + runState = createRunState(); + statusMessage = "连接已失效,请重新连接。"; + statusTone = "error"; + render(); +} + +function connectAndVerify(): void { + if (runState.phase !== "idle") return; + if (environmentTransport[environment] === "blocked") { + statusMessage = "当前环境无法连接,请重新开始。"; + statusTone = "error"; + render(); + return; + } + + const input = app.querySelector("[data-oat]"); + const oat = input?.value.trim() ?? ""; + if (oat.length === 0) { + statusMessage = "请输入访问令牌。"; + statusTone = "error"; + render(); + return; + } + + if (input !== null) input.value = ""; + const selectedEnvironment = environment; + const requestClient = new TemiApiClient(selectedEnvironment, oat, { + onReadableResponse: () => markEnvironmentReadable(selectedEnvironment), + onTransportFailure: () => markEnvironmentBlocked(selectedEnvironment), + }); + client = requestClient; + const requestDiscovery = new RobotDiscoveryController(requestClient, orchestrator, { + onChange: (state) => { + if (client !== requestClient) return; + syncComposerContext(state); + render(); + }, + }); + discovery = requestDiscovery; + runState = transitionRunState(runState, "verifying"); + statusMessage = "正在验证连接…"; + statusTone = "neutral"; + const connection = requestDiscovery.connect(() => { + if (client !== requestClient) return; + runState = transitionRunState(runState, "discovering"); + statusMessage = "正在读取机器人…"; + statusTone = "neutral"; + }); + render(); + + void connection.then((state) => { + if (client !== requestClient) return; + const robotCount = state.robots.data?.length ?? 0; + if (robotCount === 0) { + runState = transitionRunState(runState, "failed"); + statusMessage = "没有可用的机器人。"; + statusTone = "error"; + } else { + statusMessage = ""; + statusTone = "success"; + } + render(); + }).catch((error: unknown) => { + if (client !== requestClient) return; + if (runState.phase === "verifying" || runState.phase === "discovering") { + runState = transitionRunState(runState, "failed"); + } + const unauthorized = error instanceof TemiApiError && error.kind === "unauthorized"; + discovery = unauthorized ? null : requestDiscovery; + clearConnection(); + statusMessage = connectionErrorText(error); + statusTone = "error"; + render(); + }); +} + +function selectRobot(serialNumber: string): void { + const requestDiscovery = discovery; + const requestClient = client; + if (requestDiscovery === null || requestClient === null) return; + if (isLifecycleLocked() || runState.phase === "validating") return; + + if (serialNumber.length === 0) { + robotResourcesLoading = false; + requestDiscovery.clearSelection(); + if (runState.phase === "ready") runState = transitionRunState(runState, "composing"); + editingValidatedSequence = false; + addActionMenuOpen = false; + actionMenuId = null; + statusMessage = ""; + render(); + return; + } + + if (runState.phase === "ready") runState = transitionRunState(runState, "composing"); + const phase = runState.phase; + pendingRobotSerialNumber = serialNumber; + robotResourcesLoading = true; + editingValidatedSequence = false; + statusMessage = "正在读取机器人资源…"; + statusTone = "neutral"; + render(); + + void requestDiscovery.selectRobot(serialNumber, phase).then((state) => { + if (client !== requestClient || discovery !== requestDiscovery) return; + robotResourcesLoading = false; + if (runState.phase === "discovering") runState = transitionRunState(runState, "composing"); + const failedResources = [state.status, state.locations, state.contacts].filter((slot) => slot.status === "failed").length; + statusMessage = failedResources === 0 ? "" : "部分资源读取失败,可重新读取。"; + statusTone = failedResources === 0 ? "success" : "error"; + render(); + }).catch((error: unknown) => { + handleDiscoveryError(error, requestClient, requestDiscovery, "读取机器人"); + }); +} + +function refreshRobotResources(): void { + const serialNumber = discovery?.state.selectedRobot?.serialNumber; + if (serialNumber !== undefined) selectRobot(serialNumber); +} + +function refreshStatus(): void { + const requestDiscovery = discovery; + const requestClient = client; + if (requestDiscovery === null || requestClient === null || requestDiscovery.state.selectedRobot === null) return; + if (isLifecycleLocked()) return; + statusMessage = "正在刷新状态…"; + statusTone = "neutral"; + render(); + + void requestDiscovery.refreshStatus(runState.phase).then((state) => { + if (client !== requestClient || discovery !== requestDiscovery) return; + statusMessage = state.status.status === "succeeded" ? "" : "状态刷新失败。"; + statusTone = state.status.status === "succeeded" ? "success" : "error"; + render(); + }).catch((error: unknown) => { + handleDiscoveryError(error, requestClient, requestDiscovery, "刷新状态"); + }); +} + +function preparePlay(): void { + const currentComposer = composer; + const requestClient = client; + const requestDiscovery = discovery; + if (currentComposer === null || requestClient === null || requestDiscovery === null) return; + if (environmentTransport[environment] === "blocked") { + statusMessage = "当前环境无法连接,不能运行。"; + statusTone = "error"; + render(); + return; + } + if (!(runState.phase === "ready" || runState.phase === "terminal") || isLifecycleLocked()) return; + + const request = currentComposer.getValidatedRequest(); + if (request === null) { + statusMessage = "请先检查动作。"; + statusTone = "error"; + render(); + return; + } + + lifecycle?.dispose(); + let instance!: PlayPollStopLifecycle; + instance = new PlayPollStopLifecycle({ + client: requestClient, + environment, + orchestrator, + onChange: (snapshot) => { + if (lifecycle !== instance) return; + syncRunStateFromLifecycle(snapshot); + render(); + }, + onStatus: (status) => { + if (lifecycle !== instance || discovery !== requestDiscovery) return; + requestDiscovery.recordStatusSnapshot(status); + }, + onWarning: (message) => { + if (lifecycle !== instance) return; + statusMessage = message; + statusTone = "error"; + render(); + }, + }); + lifecycle = instance; + editingValidatedSequence = false; + statusMessage = "正在刷新机器人状态…"; + statusTone = "neutral"; + render(); + + void instance.preparePlay(request).then((snapshot) => { + if (lifecycle !== instance) return; + if (snapshot.phase === "confirmation_required") { + statusMessage = ""; + statusTone = "success"; + } else { + statusMessage = `机器人当前${statusLabelFor(snapshot.latestStatus?.status, "zh")},暂时无法运行。`; + statusTone = "error"; + } + render(); + }).catch((error: unknown) => { + handleLifecycleError(error, instance, requestClient, "状态检查"); + }); +} + +function confirmPlay(): void { + const instance = lifecycle; + const requestClient = client; + if (instance === null || requestClient === null || instance.state.phase !== "confirmation_required") return; + if (environmentTransport[environment] === "blocked") { + instance.cancelPreparation(); + instance.dispose(); + lifecycle = null; + statusMessage = "当前环境无法连接,不能运行。"; + statusTone = "error"; + render(); + return; + } + statusMessage = "正在启动…"; + statusTone = "neutral"; + render(); + + void instance.confirmPlay().then((snapshot) => { + if (lifecycle !== instance) return; + statusMessage = snapshot.phase === "running" ? "" : "未能启动运行。"; + statusTone = snapshot.phase === "running" ? "success" : "error"; + render(); + }).catch((error: unknown) => { + handleLifecycleError(error, instance, requestClient, "启动运行"); + }); +} + +function stopRun(): void { + const instance = lifecycle; + const requestClient = client; + if (instance === null || requestClient === null) return; + statusMessage = "正在停止…"; + statusTone = "neutral"; + render(); + + void instance.stop().then((snapshot) => { + if (lifecycle !== instance) return; + statusMessage = snapshot.phase === "stopping" ? "" : "停止请求未被确认。"; + statusTone = snapshot.phase === "stopping" ? "success" : "error"; + render(); + }).catch((error: unknown) => { + handleLifecycleError(error, instance, requestClient, "停止运行"); + }); +} + +function validateSequence(): void { + const currentComposer = composer; + const requestClient = client; + if (currentComposer === null || requestClient === null || isLifecycleLocked()) return; + + if (currentComposer.state.validation.status === "succeeded" && (runState.phase === "ready" || runState.phase === "terminal")) { + editingValidatedSequence = false; + statusMessage = ""; + render(); + return; + } + if (runState.phase === "validating") return; + if (!(runState.phase === "composing" || runState.phase === "terminal")) return; + + const inspection = currentComposer.inspect(); + if (!inspection.valid) { + statusMessage = "请修正标出的内容。"; + statusTone = "error"; + void currentComposer.validate(requestClient, orchestrator); + render(); + return; + } + + runState = transitionRunState(runState, "validating"); + statusMessage = "正在检查动作…"; + statusTone = "neutral"; + render(); + + void currentComposer.validate(requestClient, orchestrator).then((validation) => { + if (composer !== currentComposer || client !== requestClient || runState.phase !== "validating") return; + if (validation.status === "succeeded") { + runState = transitionRunState(runState, "ready"); + editingValidatedSequence = false; + statusMessage = ""; + statusTone = "success"; + } else { + runState = transitionRunState(runState, "composing"); + statusMessage = "请修正标出的内容。"; + statusTone = "error"; + } + render(); + }, (error: unknown) => { + if (composer !== currentComposer || client !== requestClient) return; + if (runState.phase === "validating") runState = transitionRunState(runState, "composing"); + if (error instanceof TemiApiError && error.kind === "unauthorized") { + requestClient.clearToken(); + composer = null; + discovery = null; + clearConnection(); + runState = createRunState(); + statusMessage = "连接已失效,请重新连接。"; + } else { + statusMessage = "动作检查失败,请修正后重试。"; + } + statusTone = "error"; + render(); + }); +} + +function syncComposerContext(state: DiscoveryState): void { + const selectedRobot = state.selectedRobot; + if (selectedRobot === null) { + composer = null; + return; + } + + if (composer === null) { + let instance!: SequenceComposer; + instance = new SequenceComposer({ + onChange: (composerState) => { + if (composer !== instance) return; + if (["ready", "terminal"].includes(runState.phase) && composerState.validation.status !== "succeeded") { + runState = transitionRunState(runState, "composing"); + } + render(); + }, + }); + composer = instance; + } + + const context: ComposerContext = { + environment, + serialNumber: selectedRobot.serialNumber, + locations: state.locations.status === "succeeded" ? locationOptions(state) : null, + contacts: state.contacts.status === "succeeded" ? contactOptions(state) : null, + }; + try { + composer.setContext(context); + } catch (error: unknown) { + if (!(error instanceof SequenceComposerError && error.kind === "validation_pending")) throw error; + } +} + +function syncRunStateFromLifecycle(snapshot: LifecycleSnapshot): void { + const identity = snapshot.identity; + try { + if (snapshot.phase === "starting" && ["ready", "terminal"].includes(runState.phase) && identity !== null) { + runState = beginRun(runState, identity.serialNumber); + } else if (snapshot.phase === "running" && identity !== null && identity.sequenceId !== null) { + if (runState.phase === "starting") runState = acceptRun(runState, identity.sequenceId); + else if (runState.phase === "unknown") runState = recoverUnknown(runState); + else if (runState.phase === "stopping") runState = transitionRunState(runState, "running"); + } else if (snapshot.phase === "stopping" && ["running", "unknown"].includes(runState.phase)) { + runState = requestStop(runState); + } else if (snapshot.phase === "terminal" && ["running", "stopping", "unknown"].includes(runState.phase)) { + runState = transitionRunState(runState, "terminal"); + } else if (snapshot.phase === "unknown" && ["starting", "running", "stopping"].includes(runState.phase)) { + runState = transitionRunState(runState, "unknown"); + } else if (snapshot.phase === "failed" && runState.phase === "starting") { + runState = transitionRunState(runState, "failed"); + } + } catch (error: unknown) { + statusMessage = error instanceof Error ? error.message : "运行状态异常。"; + statusTone = "error"; + } +} + +function handleLifecycleError(error: unknown, instance: PlayPollStopLifecycle, requestClient: TemiApiClient, operation: string): void { + if (lifecycle !== instance) return; + const snapshot = instance.state; + if (error instanceof TemiApiError && error.kind === "unauthorized") { + requestClient.clearToken(); + statusMessage = snapshot.warning ?? "连接已失效,访问令牌已清除。"; + if (snapshot.phase === "failed") { + composer = null; + discovery = null; + clearConnection(); + if (runState.phase === "ready") runState = transitionRunState(runState, "failed"); + } + } else if (snapshot.warning !== null) { + statusMessage = snapshot.warning; + } else { + statusMessage = `${operation}失败;不会自动重试运行或停止请求。`; + } + statusTone = "error"; + render(); +} + +function handleComposerError(error: unknown, operation: string): void { + statusMessage = error instanceof SequenceComposerError && error.kind === "resource_unavailable" + ? "所需资源尚不可用。" + : `${operation}失败。`; + statusTone = "error"; + render(); +} + +function handleDiscoveryError( + error: unknown, + requestClient: TemiApiClient, + requestDiscovery: RobotDiscoveryController, + operation: string, +): void { + if (client !== requestClient || discovery !== requestDiscovery) return; + if (error instanceof RobotSelectionError) { + robotResourcesLoading = false; + statusMessage = "请选择列表中的机器人。"; + statusTone = "error"; + render(); + return; + } + if (error instanceof TemiApiError && error.kind === "unauthorized") { + robotResourcesLoading = false; + requestClient.clearToken(); + discovery = null; + clearConnection(); + runState = createRunState(); + statusMessage = "连接已失效,请重新连接。"; + } else { + robotResourcesLoading = false; + statusMessage = `${operation}失败:${failureText(discoveryFailure(error))}`; + } + statusTone = "error"; + render(); +} + +function resetPage(message: string): void { + orchestrator.abortReads(); + lifecycle?.dispose(); + lifecycle = null; + discovery = null; + composer = null; + clearConnection(); + runState = createRunState(); + environmentTransport = { production: "unknown", integration: "unknown" }; + pendingRobotSerialNumber = null; + robotResourcesLoading = false; + editingValidatedSequence = false; + addActionMenuOpen = false; + actionMenuId = null; + draggedActionId = null; + timeline.clear(); + statusMessage = message; + statusTone = "neutral"; + render(); +} + +function clearConnection(): void { + const input = app.querySelector("[data-oat]"); + if (input !== null) input.value = ""; + client?.clearToken(); + client = null; + robotStatusPanels.clear(); + robotStatusFilter = "all"; + robotStatusesRefreshed = false; + robotStatusesRefreshing = false; + pendingRobotSerialNumber = null; +} + +function isLifecycleLocked(): boolean { + const phase = lifecycle?.state.phase; + return hasActiveRun(runState) || ( + phase !== undefined && ["preflighting", "confirmation_required", "starting", "running", "stopping", "unknown"].includes(phase) + ); +} + +function locationOptions(state: DiscoveryState): readonly ComposerOption[] { + return (state.locations.data?.locations ?? []).flatMap((location) => { + if (!isNonEmptyText(location.name)) return []; + return [{ value: location.name, label: location.name }]; + }); +} + +function contactOptions(state: DiscoveryState): readonly ComposerOption[] { + return (state.contacts.data?.contacts ?? []).flatMap((contact) => { + if (!isNonEmptyText(contact.temiId)) return []; + return [{ value: contact.temiId, label: isNonEmptyText(contact.name) ? contact.name : copy("未命名联系人", "Unnamed contact") }]; + }); +} + +function actionValidationIssue(state: SequenceComposerState, actionId: string): string | null { + const issue = state.validation.issues.find((candidate) => candidate.actionId === actionId); + return issue === undefined ? null : validationIssueText(issue.field); +} + +function validationIssueText(field: "sequence" | "location" | "tts" | "contactIds" | "step"): string { + if (field === "location") return copy("请选择位置。", "Select a location."); + if (field === "tts") return copy("请输入播报内容。", "Enter text for the robot to speak."); + if (field === "contactIds") return copy("请选择至少一位联系人。", "Select at least one contact."); + if (field === "step") return copy("动作顺序无效。", "The action order is invalid."); + return copy("至少添加一个动作。", "Add at least one action."); +} + +function validationErrorText(error: NonNullable): string { + if (error.kind === "unauthorized") return copy("连接已失效,请重新连接。", "The connection expired. Reconnect and try again."); + if (error.kind === "network") return copy("网络请求失败,请稍后重试。", "The network request failed. Try again shortly."); + if (error.kind === "timeout") return copy("检查超时,请稍后重试。", "The check timed out. Try again shortly."); + if (error.kind === "http") return copy("动作未通过服务端检查,请修正后重试。", "The server rejected the actions. Fix them and try again."); + return copy("动作检查失败,请重试。", "The action check failed. Try again."); +} + +function actionAvailabilityText(state: DiscoveryState, type: SequenceActionDraft["type"]): string { + const availability = actionAvailability(state, type); + if (availability.enabled) return copy("可添加", "Available"); + if (type === "MOVEMENT") return copy("位置尚不可用", "Locations are not available yet"); + if (type === "START_CALL") return copy("联系人尚不可用", "Contacts are not available yet"); + return copy("暂不可用", "Not available yet"); +} + +function actionLabel(type: SequenceActionDraft["type"]): string { + if (type === "MOVEMENT") return copy("前往位置", "Go to location"); + if (type === "SPEAK") return copy("播报文字", "Speak text"); + return copy("发起呼叫", "Start call"); +} + +function actionSummary(action: SequenceActionDraft, context: ComposerContext | null): string { + if (action.type === "MOVEMENT") return action.location || copy("未选择位置", "No location selected"); + if (action.type === "SPEAK") return action.tts || copy("未填写内容", "No text entered"); + if (action.contactIds.length === 0) return copy("未选择联系人", "No contacts selected"); + return action.contactIds.map((id) => context?.contacts?.find((contact) => contact.value === id)?.label ?? id).join(copy("、", ", ")); +} + +function runningTitle(snapshot: LifecycleSnapshot): string { + if (snapshot.phase === "starting") return copy("正在启动", "Starting"); + if (snapshot.phase === "running") return copy("正在运行", "Running"); + if (snapshot.phase === "stopping") return copy("正在停止", "Stopping"); + if (snapshot.phase === "unknown") return copy("结果未知", "Result unknown"); + if (snapshot.phase === "failed") return copy("启动失败", "Start failed"); + if (snapshot.observation === "completed") return copy("已完成", "Completed"); + if (snapshot.observation === "stopped") return copy("已停止", "Stopped"); + if (snapshot.observation === "aborted") return copy("已中止", "Aborted"); + if (snapshot.observation === "identity_mismatch") return copy("运行已变化", "Run changed"); + return copy("已结束", "Ended"); +} + +function runningSubtitle(snapshot: LifecycleSnapshot): string { + if (snapshot.phase === "starting") return copy("正在等待服务接受运行。", "Waiting for the service to accept the run."); + if (snapshot.phase === "running") return copy("正在等待机器人更新状态。", "Waiting for the robot to update its status."); + if (snapshot.phase === "stopping") return copy("停止请求已发送,正在等待终态。", "The stop request was sent. Waiting for a final state."); + if (snapshot.phase === "unknown") return copy("只会继续读取状态,不会自动重发运行或停止请求。", "Only status reads will continue. Run and stop requests will not be retried automatically."); + if (snapshot.phase === "failed") return copy("运行请求未成功。", "The run request did not succeed."); + if (snapshot.observation === "identity_mismatch") return copy("机器人返回了另一个运行标识,当前结果不能归因于本次运行。", "The robot returned a different run ID, so this result cannot be attributed to the current run."); + return copy("本次运行已经结束。", "This run has ended."); +} + +function lifecyclePhaseLabel(phase: LifecycleSnapshot["phase"]): string { + if (phase === "starting") return copy("启动中", "Starting"); + if (phase === "running") return copy("运行中", "Running"); + if (phase === "stopping") return copy("停止中", "Stopping"); + if (phase === "terminal") return copy("已结束", "Ended"); + if (phase === "failed") return copy("失败", "Failed"); + if (phase === "unknown") return copy("未知", "Unknown"); + return copy("准备中", "Preparing"); +} + +function lifecycleErrorText(snapshot: LifecycleSnapshot): string { + const error = snapshot.error; + if (error === null) return copy("运行状态未知。", "The run status is unknown."); + if (error.kind === "unauthorized") return copy("连接已失效,访问令牌已清除。", "The connection expired and the access token was cleared."); + if (error.kind === "network") return copy("网络请求失败。", "The network request failed."); + if (error.kind === "timeout") return copy("请求超时。", "The request timed out."); + if (error.kind === "http") return copy(`请求失败(HTTP ${error.status ?? "unknown"})。`, `Request failed (HTTP ${error.status ?? "unknown"}).`); + return copy("请求结果未知。", "The request result is unknown."); +} + +function connectionErrorText(error: unknown): string { + if (error instanceof TemiApiError && error.kind === "unauthorized") return "访问令牌无效或已过期。"; + if (error instanceof TemiApiError && error.kind === "timeout") return "连接超时,请检查网络后重试。"; + if (error instanceof TemiApiError && error.kind === "http") return `连接被拒绝(HTTP ${error.status ?? "unknown"})。`; + return "连接失败,请检查网络或浏览器访问限制。"; +} + +function robotStatusErrorText(error: unknown): string { + if (error instanceof TemiApiError && error.kind === "timeout") return "状态读取超时。"; + if (error instanceof TemiApiError && error.kind === "network") return "状态读取失败,请检查网络。"; + if (error instanceof TemiApiError && error.kind === "http" && error.status === 403) return "无权读取该机器人状态。"; + if (error instanceof TemiApiError && error.kind === "http") return `状态读取失败(HTTP ${error.status ?? "unknown"})。`; + return "状态读取失败。"; +} + +function robotBatteryText(status: RobotStatus): string { + const values: string[] = []; + if (typeof status.battery?.level === "number" && Number.isFinite(status.battery.level)) { + values.push(`${status.battery.level}%`); + } + if (typeof status.battery?.isCharging === "boolean") { + values.push(status.battery.isCharging ? copy("充电中", "Charging") : copy("未充电", "Not charging")); + } + return values.length === 0 ? copy("未知", "Unknown") : values.join(" · "); +} + +function robotMovementText(status: RobotStatus): string { + return compactRobotStatusValue([ + status.movement?.type, + status.movement?.status, + status.movement?.location, + ]); +} + +function robotSequenceText(status: RobotStatus): string { + const step = status.sequence?.step; + const total = status.sequence?.total; + const progress = typeof step === "number" && typeof total === "number" ? `${step}/${total}` : undefined; + return compactRobotStatusValue([ + status.sequence?.status, + status.sequence?.name, + progress, + ]); +} + +function robotCallText(status: RobotStatus): string { + return compactRobotStatusValue([status.call?.status]); +} + +function compactRobotStatusValue(values: Array): string { + const known = values.filter((value): value is string => typeof value === "string" && value.trim().length > 0); + return known.length === 0 ? copy("未知", "Unknown") : known.join(" · "); +} + +function failureText(failure: DiscoveryFailure | null): string { + if (failure === null) return "请求失败。"; + if (failure.kind === "unauthorized") return "连接已失效。"; + if (failure.kind === "network") return "网络不可用。"; + if (failure.kind === "timeout") return "请求超时。"; + if (failure.kind === "http") return `HTTP ${failure.status ?? "unknown"}。`; + return "请求失败。"; +} + +function statusLabel(status: RobotStatus["status"] | undefined): string { + return statusLabelFor(status, language); +} + +function statusLabelFor(status: RobotStatus["status"] | undefined, targetLanguage: Language): string { + if (status === "online") return targetLanguage === "zh" ? "在线" : "Online"; + if (status === "busy") return targetLanguage === "zh" ? "忙碌" : "Busy"; + if (status === "offline") return targetLanguage === "zh" ? "离线" : "Offline"; + if (status === "privacy") return targetLanguage === "zh" ? "隐私模式" : "Privacy mode"; + return targetLanguage === "zh" ? "状态未知" : "Status unknown"; +} + +function environmentLabel(value: Environment): string { + return value === "production" ? "Production" : "Integration"; +} + +function displayName(value: string | undefined, fallback: string): string { + return escapeHtml(isNonEmptyText(value) ? value : fallback); +} + +function formatClock(value: number): string { + return new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(value); +} + +function markEnvironmentReadable(value: Environment): void { + if (environmentTransport[value] === "unknown") environmentTransport[value] = "readable"; +} + +function markEnvironmentBlocked(value: Environment): void { + environmentTransport[value] = "blocked"; +} + +function isNonEmptyText(value: string | undefined): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isSequenceActionType(value: string | undefined): value is SequenceActionDraft["type"] { + return value === "MOVEMENT" || value === "SPEAK" || value === "START_CALL"; +} + +function isMoveDirection(value: string | undefined): value is "up" | "down" { + return value === "up" || value === "down"; +} + +function isLanguage(value: string | undefined): value is Language { + return value === "zh" || value === "en"; +} + +function localizeRuntimeText(value: string): string { + if (language === "zh") return value; + const exact = runtimeEnglish[value]; + if (exact !== undefined) return exact; + + const robotUnavailable = /^机器人当前(.+),暂时无法运行。$/.exec(value); + if (robotUnavailable !== null) { + return `The robot is currently ${runtimeStatusEnglish(robotUnavailable[1])} and cannot run yet.`; + } + + const noRetryFailure = /^(.+)失败;不会自动重试运行或停止请求。$/.exec(value); + if (noRetryFailure !== null) { + return `${runtimeOperationEnglish(noRetryFailure[1])} failed. Run and stop requests will not be retried automatically.`; + } + + const detailedFailure = /^(.+)失败:(.+)$/.exec(value); + if (detailedFailure !== null) { + return `${runtimeOperationEnglish(detailedFailure[1])} failed: ${localizeRuntimeText(detailedFailure[2])}`; + } + + const simpleFailure = /^(.+)失败。$/.exec(value); + if (simpleFailure !== null) return `${runtimeOperationEnglish(simpleFailure[1])} failed.`; + + const httpFailure = /^连接被拒绝(HTTP (.+))。$/.exec(value); + if (httpFailure !== null) return `Connection was rejected (HTTP ${httpFailure[1]}).`; + const statusHttpFailure = /^状态读取失败(HTTP (.+))。$/.exec(value); + if (statusHttpFailure !== null) return `Could not read status (HTTP ${statusHttpFailure[1]}).`; + const bareHttpFailure = /^HTTP (.+)。$/.exec(value); + if (bareHttpFailure !== null) return `HTTP ${bareHttpFailure[1]}.`; + + return value; +} + +function runtimeOperationEnglish(operation: string): string { + const operations: Readonly> = { + "添加动作": "Add action", + "删除动作": "Delete action", + "调整顺序": "Reorder actions", + "修改位置": "Update location", + "修改播报": "Update speech", + "修改联系人": "Update contacts", + "读取机器人": "Load robot", + "刷新状态": "Refresh status", + "状态检查": "Status check", + "启动运行": "Start run", + "停止运行": "Stop run", + }; + return operations[operation] ?? operation; +} + +function runtimeStatusEnglish(status: string): string { + const statuses: Readonly> = { + "在线": "online", + "忙碌": "busy", + "离线": "offline", + "隐私模式": "in privacy mode", + "状态未知": "in an unknown state", + }; + return statuses[status] ?? status; +} + +function copy(chinese: string, english: string): string { + return language === "zh" ? chinese : english; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +window.addEventListener("pagehide", () => { + orchestrator.abortReads(); + lifecycle?.dispose(); + clearConnection(); +}); + +window.addEventListener("beforeunload", (event) => { + if (!isLifecycleLocked()) return; + event.preventDefault(); + event.returnValue = copy( + "机器人可能继续执行,且本页关闭后无法恢复当前运行。", + "The robot may continue running, and this run cannot be recovered after closing the page.", + ); +}); + +document.addEventListener("visibilitychange", () => { + if (lifecycle !== null && document.visibilityState === "visible") render(); +}); + +render(); diff --git a/demos/temi-openapi-scenario-runner/src/orchestrator.ts b/demos/temi-openapi-scenario-runner/src/orchestrator.ts new file mode 100644 index 0000000..bd9a7f3 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/src/orchestrator.ts @@ -0,0 +1,235 @@ +import { + TemiApiError, + type TemiApiRequestOptions, +} from "./api"; +import { + API_OPERATION_CONTRACTS, + TimelineStore, + type ApiOperation, + type TimelineOutcome, +} from "./timeline"; +import type { RunState } from "./run-state"; + +export type RequestOrchestratorOptions = Readonly<{ + timeline?: TimelineStore; + now?: () => number; + timeoutMs?: number; +}>; + +export type OrchestratedRequest = Readonly<{ + phase: RunState; + operation: ApiOperation; + requestSummary?: unknown; + timeoutMs?: number; +}>; + +export type ApiRequestInvoker = (options: TemiApiRequestOptions) => Promise; + +export type RequestCompletion = Readonly<{ + eventId: number; + outcome: Exclude; + httpStatus: number | null; + response?: T; + error?: unknown; +}>; + +export type RequestHooks = Readonly<{ + validateResponse?: (response: T) => void; + onSettled?: (completion: RequestCompletion) => void; +}>; + +type ActiveRequest = { + id: number; + phase: RunState; + operation: ApiOperation; + controller: AbortController; +}; + +export class RequestOrchestrator { + readonly timeline: TimelineStore; + #timeoutMs: number; + #active = new Map(); + + constructor(options: RequestOrchestratorOptions = {}) { + const now = options.now ?? (() => Date.now()); + this.#timeoutMs = options.timeoutMs ?? 10_000; + this.timeline = options.timeline ?? new TimelineStore(now); + } + + execute( + request: OrchestratedRequest, + invoke: ApiRequestInvoker, + hooks: RequestHooks = {}, + ): Promise { + const id = this.timeline.enqueue(request); + const controller = new AbortController(); + const active: ActiveRequest = { + id, + phase: request.phase, + operation: request.operation, + controller, + }; + this.#active.set(id, active); + this.timeline.start(id); + + let httpStatus: number | null = null; + let timeoutTriggered = false; + const timeoutMs = request.timeoutMs ?? this.#timeoutMs; + const timeout = + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? globalThis.setTimeout(() => { + timeoutTriggered = true; + controller.abort("timeout"); + }, timeoutMs) + : null; + + const options: TemiApiRequestOptions = { + signal: controller.signal, + onResponseStatus: (status) => { + httpStatus = status; + }, + }; + + let requestPromise: Promise; + try { + requestPromise = invoke(options); + } catch (error: unknown) { + requestPromise = Promise.reject(error); + } + + return requestPromise.then( + (response) => { + const forcedError = forcedAbortError(controller.signal, timeoutTriggered); + if (forcedError !== null) { + return this.finishFailure(active, timeout, httpStatus, forcedError, hooks); + } + try { + hooks.validateResponse?.(response); + } catch (error: unknown) { + return this.finishFailure(active, timeout, httpStatus, error, hooks); + } + this.finishSuccess(active, timeout, httpStatus, response, hooks); + return response; + }, + (error: unknown) => this.finishFailure(active, timeout, httpStatus, error, hooks), + ); + } + + abortReads(): void { + for (const active of this.#active.values()) { + if (API_OPERATION_CONTRACTS[active.operation].sideEffect === "read") { + active.controller.abort("reset"); + } + } + } + + abort(id: number): void { + this.#active.get(id)?.controller.abort("abort"); + } + + private finishSuccess( + active: ActiveRequest, + timeout: ReturnType | null, + httpStatus: number | null, + response: T, + hooks: RequestHooks, + ): void { + this.cleanup(active.id, timeout); + if (this.timeline.has(active.id)) { + this.timeline.complete(active.id, { + outcome: "succeeded", + httpStatus, + response, + }); + } + notifySettled(hooks.onSettled, { + eventId: active.id, + outcome: "succeeded", + httpStatus, + response, + }); + } + + private finishFailure( + active: ActiveRequest, + timeout: ReturnType | null, + httpStatus: number | null, + error: unknown, + hooks: RequestHooks, + ): never { + this.cleanup(active.id, timeout); + const requestError = error instanceof Error ? error : new Error("Request failed."); + const outcome = outcomeFor(active.operation, active.phase, requestError); + if (this.timeline.has(active.id)) { + this.timeline.complete(active.id, { + outcome, + httpStatus: httpStatus ?? errorStatus(requestError), + error: requestError, + }); + } + notifySettled(hooks.onSettled, { + eventId: active.id, + outcome, + httpStatus: httpStatus ?? errorStatus(requestError), + error, + }); + throw error; + } + + private cleanup(id: number, timeout: ReturnType | null): void { + if (timeout !== null) { + globalThis.clearTimeout(timeout); + } + this.#active.delete(id); + } +} + +function notifySettled( + hook: ((completion: RequestCompletion) => void) | undefined, + completion: RequestCompletion, +): void { + try { + hook?.(completion); + } catch { + // Timeline annotations must not change the request's result. + } +} + +function forcedAbortError(signal: AbortSignal, timeoutTriggered: boolean): TemiApiError | null { + if (!signal.aborted) return null; + return new TemiApiError(timeoutTriggered ? "timeout" : "aborted", null); +} + +function outcomeFor( + operation: ApiOperation, + phase: RunState, + error: Error, +): Exclude { + const kind = error instanceof TemiApiError ? error.kind : "unknown"; + if ( + kind === "http" || + kind === "unauthorized" || + (error instanceof TemiApiError && error.status !== null && error.status >= 400) + ) { + return "failed"; + } + if (kind === "aborted") { + return API_OPERATION_CONTRACTS[operation].sideEffect === "write" ? "unknown" : "cancelled"; + } + if ( + kind === "network" || + kind === "timeout" || + kind === "invalid_response" || + kind === "unknown" + ) { + return API_OPERATION_CONTRACTS[operation].sideEffect === "write" || phase === "running" + ? "unknown" + : "failed"; + } + return "failed"; +} + +function errorStatus(error: Error): number | null { + if (error instanceof TemiApiError) return error.status; + return null; +} diff --git a/demos/temi-openapi-scenario-runner/src/run-state.ts b/demos/temi-openapi-scenario-runner/src/run-state.ts new file mode 100644 index 0000000..68e429b --- /dev/null +++ b/demos/temi-openapi-scenario-runner/src/run-state.ts @@ -0,0 +1,169 @@ +export const RUN_STATES = [ + "idle", + "verifying", + "discovering", + "composing", + "validating", + "ready", + "starting", + "running", + "stopping", + "terminal", + "failed", + "unknown", +] as const; + +export type RunState = (typeof RUN_STATES)[number]; + +export type RunIdentity = Readonly<{ + serialNumber: string; + sequenceId: string | null; +}>; + +export type RunStateSnapshot = Readonly<{ + phase: RunState; + activeRun: RunIdentity | null; +}>; + +type TransitionOptions = { + activeRun?: RunIdentity | null; +}; + +const ALLOWED_TRANSITIONS: Readonly> = Object.freeze({ + idle: ["verifying"], + verifying: ["discovering", "failed", "unknown"], + discovering: ["composing", "failed", "unknown"], + composing: ["validating", "failed", "unknown"], + validating: ["ready", "composing", "failed", "unknown"], + ready: ["starting", "composing", "failed", "unknown"], + starting: ["running", "failed", "unknown"], + running: ["stopping", "terminal", "unknown", "failed"], + stopping: ["running", "terminal", "failed", "unknown"], + terminal: ["composing", "validating", "starting"], + failed: [], + unknown: ["running", "stopping", "failed"], +}); + +export function createRunState(): RunStateSnapshot { + return freezeState({ phase: "idle", activeRun: null }); +} + +export function canTransitionRunState(from: RunState, to: RunState): boolean { + return ALLOWED_TRANSITIONS[from].includes(to); +} + +export function transitionRunState( + current: RunStateSnapshot, + next: RunState, + options: TransitionOptions = {}, +): RunStateSnapshot { + if (!canTransitionRunState(current.phase, next)) { + throw new Error(`Illegal run state transition: ${current.phase} -> ${next}.`); + } + + const requestedRun = Object.prototype.hasOwnProperty.call(options, "activeRun") + ? options.activeRun ?? null + : current.activeRun; + + if (next === "starting" && !isStartingIdentity(requestedRun)) { + throw new Error("Starting a run requires a serial number."); + } + + if (next === "running" && !isAcceptedIdentity(requestedRun)) { + throw new Error("Running a run requires a sequence identity."); + } + + if (next === "stopping" && !isAcceptedIdentity(requestedRun)) { + throw new Error("Stopping a run requires a sequence identity."); + } + + return freezeState({ + phase: next, + activeRun: activeRunForState(next, requestedRun), + }); +} + +export function beginRun(current: RunStateSnapshot, serialNumber: string): RunStateSnapshot { + if (serialNumber.trim().length === 0) { + throw new Error("Starting a run requires a serial number."); + } + return transitionRunState(current, "starting", { + activeRun: { serialNumber, sequenceId: null }, + }); +} + +export function acceptRun(current: RunStateSnapshot, sequenceId: string): RunStateSnapshot { + if (sequenceId.trim().length === 0) { + throw new Error("Accepting a run requires a sequence identity."); + } + if (current.activeRun === null) { + throw new Error("Accepting a run requires an active starting run."); + } + return transitionRunState(current, "running", { + activeRun: { ...current.activeRun, sequenceId }, + }); +} + +export function requestStop(current: RunStateSnapshot): RunStateSnapshot { + return transitionRunState(current, "stopping"); +} + +export function recoverUnknown(current: RunStateSnapshot): RunStateSnapshot { + if (current.phase !== "unknown" || !isAcceptedIdentity(current.activeRun)) { + throw new Error("Only an unknown active run can resume observation."); + } + return transitionRunState(current, "running"); +} + +export function resetRunState(): RunStateSnapshot { + return createRunState(); +} + +export function hasActiveRun(state: RunStateSnapshot): boolean { + return ( + (state.phase === "starting" || + state.phase === "running" || + state.phase === "stopping" || + state.phase === "unknown") && + state.activeRun !== null + ); +} + +export function canStartPlay(state: RunStateSnapshot): boolean { + return (state.phase === "ready" || state.phase === "terminal") && !hasActiveRun(state); +} + +export function canStopRun(state: RunStateSnapshot): boolean { + return ( + (state.phase === "running" || state.phase === "unknown") && + isAcceptedIdentity(state.activeRun) + ); +} + +function activeRunForState(next: RunState, run: RunIdentity | null): RunIdentity | null { + if ( + next === "idle" || + next === "composing" || + next === "ready" || + next === "terminal" || + next === "failed" + ) { + return null; + } + return run; +} + +function isStartingIdentity(run: RunIdentity | null): run is RunIdentity { + return run !== null && run.serialNumber.trim().length > 0 && run.sequenceId === null; +} + +function isAcceptedIdentity(run: RunIdentity | null): run is RunIdentity { + return run !== null && run.serialNumber.trim().length > 0 && run.sequenceId !== null && run.sequenceId.trim().length > 0; +} + +function freezeState(state: { phase: RunState; activeRun: RunIdentity | null }): RunStateSnapshot { + if (state.activeRun !== null) { + Object.freeze(state.activeRun); + } + return Object.freeze(state); +} diff --git a/demos/temi-openapi-scenario-runner/src/styles.css b/demos/temi-openapi-scenario-runner/src/styles.css new file mode 100644 index 0000000..8ec14e6 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/src/styles.css @@ -0,0 +1,1579 @@ +:root { + color: #111513; + background: #f5f7f5; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + --brand: #21cd99; + --brand-dark: #087255; + --ink: #111513; + --muted: #69736e; + --faint: #919a95; + --canvas: #f5f7f5; + --surface: #ffffff; + --surface-soft: #f9faf9; + --line: #e1e6e3; + --line-strong: #cbd3ce; + --danger: #bd3f3a; + --danger-soft: #fff3f2; + --warning: #966514; + --warning-soft: #fff9e8; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: var(--canvas); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +a, +select, +input, +textarea { + -webkit-tap-highlight-color: transparent; +} + +button:focus-visible, +a:focus-visible, +select:focus-visible, +input:focus-visible, +textarea:focus-visible { + outline: 3px solid rgb(33 205 153 / 28%); + outline-offset: 3px; +} + +button { + color: inherit; +} + +.app-shell { + min-height: 100vh; +} + +.app-header { + position: sticky; + z-index: 30; + top: 0; + border-bottom: 1px solid var(--line); + background: rgb(255 255 255 / 94%); + backdrop-filter: blur(12px); +} + +.header-inner { + display: flex; + width: min(100% - 40px, 1120px); + min-height: 68px; + margin: 0 auto; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.brand-lockup { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 14px; + color: var(--ink); +} + +.brand-lockup img { + display: block; + width: 148px; + height: auto; +} + +.header-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.language-picker { + position: relative; +} + +.language-button { + display: inline-flex; + min-width: 76px; + border: 0; + border-radius: 8px; + padding: 8px 10px; + align-items: center; + justify-content: space-between; + gap: 8px; + color: #4f5954; + background: transparent; + font-size: 0.82rem; + font-weight: 680; + cursor: pointer; +} + +.language-button:hover, +.language-button[aria-expanded="true"] { + color: var(--ink); + background: var(--surface-soft); +} + +.language-chevron { + display: inline-block; + color: var(--faint); + font-size: 0.9rem; + line-height: 1; + transition: transform 140ms ease; +} + +.language-chevron.open { + transform: rotate(180deg); +} + +.language-menu { + position: absolute; + z-index: 40; + top: calc(100% + 8px); + right: 0; + width: 144px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 5px; + background: var(--surface); + box-shadow: 0 12px 32px rgb(17 21 19 / 12%); +} + +.language-option { + display: flex; + width: 100%; + border: 0; + border-radius: 7px; + padding: 9px 10px; + align-items: center; + justify-content: space-between; + color: #4f5954; + background: transparent; + font-size: 0.82rem; + text-align: left; + cursor: pointer; +} + +.language-option:hover, +.language-option.selected { + color: var(--ink); + background: var(--surface-soft); +} + +.language-option.selected { + color: var(--brand-dark); + font-weight: 700; +} + +.header-button, +.text-button { + border: 0; + padding: 0; + color: #4f5954; + background: transparent; + font-size: 0.82rem; + font-weight: 680; + text-decoration: none; + cursor: pointer; +} + +.header-button:hover, +.text-button:hover:not(:disabled) { + color: var(--ink); +} + +.header-button { + border-right: 1px solid var(--line); + padding-right: 16px; +} + +.text-button:disabled { + color: var(--faint); + cursor: not-allowed; +} + +.connection-status { + display: flex; + align-items: center; + gap: 8px; + color: #4f5954; + font-size: 0.82rem; + font-weight: 650; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #b3bbb7; +} + +.status-dot.online { + background: var(--brand); + box-shadow: 0 0 0 4px rgb(33 205 153 / 14%); +} + +.app-main { + width: min(100% - 40px, 960px); + margin: 0 auto; + padding: 48px 0 64px; +} + +.progress { + margin: 0 0 46px; +} + +.progress-list { + display: grid; + margin: 0; + padding: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + list-style: none; +} + +.progress-step { + position: relative; + display: flex; + align-items: center; + gap: 10px; + color: var(--faint); + font-size: 0.8rem; + font-weight: 680; +} + +.progress-step::after { + position: absolute; + z-index: -1; + top: 14px; + right: 14px; + left: 14px; + height: 1px; + background: var(--line); + content: ""; +} + +.progress-step:first-child::after { + left: 28px; +} + +.progress-step:last-child::after { + right: calc(100% - 28px); +} + +.progress-step:nth-child(2) { + justify-content: center; +} + +.progress-step:last-child { + justify-content: flex-end; +} + +.step-index { + position: relative; + z-index: 1; + display: inline-grid; + width: 28px; + height: 28px; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 999px; + color: #6f7873; + background: var(--canvas); + font-size: 0.72rem; + font-weight: 800; +} + +.progress-step.current, +.progress-step.complete { + color: var(--ink); +} + +.progress-step.current .step-index { + border-color: var(--brand); + color: #073c2e; + background: var(--brand); +} + +.progress-step.complete .step-index { + border-color: #9de6d0; + color: var(--brand-dark); + background: #e8fff8; +} + +.stage { + animation: stage-in 180ms ease-out; +} + +@keyframes stage-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.stage-head { + display: flex; + margin-bottom: 26px; + align-items: flex-end; + justify-content: space-between; + gap: 24px; +} + +.stage-head h1, +.modal h2 { + margin: 0; + color: var(--ink); + letter-spacing: -0.035em; +} + +.stage-head h1 { + font-size: clamp(1.9rem, 4vw, 2.65rem); + line-height: 1.08; +} + +.stage-head p { + margin: 10px 0 0; + color: var(--muted); + line-height: 1.55; +} + +.stage-count { + flex: 0 0 auto; + color: var(--muted); + font-size: 0.85rem; + font-weight: 650; +} + +.robot-list-tools { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.robot-list-tool { + display: inline-flex; + min-height: 32px; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 6px 10px; + color: #4f5954; + background: var(--surface); + font-size: 0.76rem; + font-weight: 680; + cursor: pointer; +} + +.robot-list-tool:hover:not(:disabled) { + border-color: var(--line-strong); + color: var(--ink); +} + +.robot-list-tool.active { + border-color: #9de6d0; + color: var(--brand-dark); + background: #effff9; +} + +.robot-list-tool:disabled { + color: var(--faint); + background: #f3f5f4; + cursor: not-allowed; +} + +.refresh-symbol { + font-size: 1rem; + line-height: 1; +} + +.refresh-symbol.spinning { + animation: refresh-spin 900ms linear infinite; +} + +@keyframes refresh-spin { + to { + transform: rotate(360deg); + } +} + +.connect-wrap { + display: grid; + min-height: calc(100vh - 230px); + place-items: center; +} + +.connect-card { + width: min(100%, 470px); + border: 1px solid var(--line); + border-radius: 14px; + padding: 34px; + background: var(--surface); +} + +.connect-form { + display: grid; + gap: 20px; +} + +.reconnect-button { + margin-top: 12px; +} + +.field { + display: grid; + gap: 8px; +} + +.field label, +.field-label { + color: #313733; + font-size: 0.82rem; + font-weight: 720; +} + +.field input, +.field select, +.field textarea { + width: 100%; + border: 1px solid var(--line-strong); + border-radius: 10px; + padding: 11px 13px; + color: var(--ink); + background: var(--surface); + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.field input, +.field select { + min-height: 44px; +} + +.field textarea { + min-height: 92px; + line-height: 1.5; + resize: vertical; +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + border-color: #73dabc; + box-shadow: 0 0 0 3px rgb(33 205 153 / 12%); + outline: none; +} + +.field input:disabled, +.field select:disabled, +.field textarea:disabled { + color: #7f8883; + background: #f3f5f4; +} + +.field-note, +.field-error { + margin: 0; + font-size: 0.76rem; + line-height: 1.45; +} + +.field-note { + color: var(--muted); +} + +.field-error { + color: var(--danger); + font-weight: 650; +} + +.button { + display: inline-flex; + min-height: 42px; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--line-strong); + border-radius: 10px; + padding: 9px 15px; + color: var(--ink); + background: var(--surface); + font-size: 0.86rem; + font-weight: 740; + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease, transform 150ms ease; +} + +.button:hover:not(:disabled) { + border-color: #aab4ae; + background: #f7f9f7; +} + +.button:active:not(:disabled) { + transform: translateY(1px); +} + +.button.primary { + border-color: var(--brand); + color: #06291f; + background: var(--brand); +} + +.button.primary:hover:not(:disabled) { + border-color: #1abb8c; + background: #1abb8c; +} + +.button.danger { + border-color: #e5b9b6; + color: var(--danger); + background: #fff; +} + +.button.danger:hover:not(:disabled) { + border-color: #d99a96; + background: var(--danger-soft); +} + +.button.ghost { + border-color: transparent; + background: transparent; +} + +.button:disabled { + color: #9aa29e; + border-color: #e4e8e5; + background: #edf0ee; + cursor: not-allowed; +} + +.button.wide { + width: 100%; +} + +.inline-notice, +.loading-panel { + margin: 16px 0 0; + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px 14px; + color: #4d5651; + background: var(--surface); + font-size: 0.8rem; + line-height: 1.5; +} + +.inline-notice.success { + border-color: #b9eadb; + color: #075b45; + background: #f2fff9; +} + +.inline-notice.error { + border-color: #efc8c5; + color: #8c302c; + background: var(--danger-soft); +} + +.loading-panel { + display: flex; + min-height: 130px; + margin-top: 0; + align-items: center; + justify-content: center; + gap: 10px; +} + +.loading-dot { + display: inline-block; + width: 9px; + height: 9px; + flex: 0 0 auto; + border-radius: 999px; + background: var(--brand); + animation: loading-pulse 1.3s ease-in-out infinite; +} + +@keyframes loading-pulse { + 50% { + opacity: 0.35; + transform: scale(0.78); + } +} + +.robot-grid { + display: grid; + align-items: start; + gap: 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.robot-card { + display: grid; + min-height: 154px; + align-content: space-between; + gap: 22px; + border: 1px solid var(--line); + border-radius: 12px; + padding: 19px; + text-align: left; + background: var(--surface); + transition: border-color 150ms ease, background 150ms ease, transform 150ms ease; +} + +.robot-card:hover { + border-color: #aeb8b2; + transform: translateY(-1px); +} + +.robot-card.selected { + border-color: var(--brand); + background: #f1fff9; + box-shadow: 0 0 0 2px rgb(33 205 153 / 13%); +} + +.robot-card.expanded { + align-content: start; + gap: 18px; +} + +.robot-select-control { + display: grid; + width: 100%; + border: 0; + padding: 0; + gap: 16px; + text-align: left; + background: transparent; + cursor: pointer; +} + +.robot-card-top, +.robot-context, +.review-robot, +.run-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.robot-card-top { + width: 100%; +} + +.robot-card-top > span:first-child { + min-width: 0; +} + +.robot-name { + display: block; + overflow: hidden; + color: var(--ink); + font-size: 1rem; + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +.robot-serial, +.robot-context p, +.review-robot p, +.run-hero p { + display: block; + margin: 4px 0 0; + color: var(--muted); + font-size: 0.76rem; +} + +.robot-select-mark { + display: inline-grid; + width: 26px; + height: 26px; + flex: 0 0 auto; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 999px; + color: #08684e; + background: #fff; + font-size: 0.75rem; + font-weight: 800; +} + +.robot-card.selected .robot-select-mark { + border-color: #9de6d0; + background: #dffff5; +} + +.robot-card-status { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 0.75rem; + font-weight: 680; +} + +.robot-card-status-dot { + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 999px; + background: #aeb6b2; +} + +.robot-card-status.online { + color: var(--brand-dark); +} + +.robot-card-status.online .robot-card-status-dot { + background: var(--brand); +} + +.robot-card-status.busy, +.robot-card-status.privacy { + color: var(--warning); +} + +.robot-card-status.busy .robot-card-status-dot, +.robot-card-status.privacy .robot-card-status-dot { + background: #d5a12e; +} + +.robot-card-status.error { + color: var(--danger); +} + +.robot-card-status.error .robot-card-status-dot { + background: #d76b65; +} + +.robot-card-status.refreshing .robot-card-status-dot { + background: var(--brand); + animation: loading-pulse 1.3s ease-in-out infinite; +} + +.robot-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.robot-card-action, +.robot-status-toggle { + border: 0; + padding: 0; + color: var(--muted); + background: transparent; + font-size: 0.76rem; + font-weight: 680; + cursor: pointer; +} + +.robot-card-action:hover, +.robot-status-toggle:hover { + color: var(--ink); +} + +.robot-status-toggle { + color: var(--brand-dark); +} + +.robot-status-panel { + min-width: 0; + border-top: 1px solid var(--line); + padding-top: 16px; +} + +.robot-status-loading, +.robot-status-error, +.robot-status-snapshot { + margin: 0; + font-size: 0.72rem; + line-height: 1.45; +} + +.robot-status-loading { + display: flex; + min-height: 44px; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--muted); +} + +.robot-status-loading.compact { + min-height: auto; + margin-bottom: 10px; + justify-content: flex-start; +} + +.robot-status-error { + color: var(--danger); + font-weight: 650; +} + +.robot-status-facts { + display: grid; + margin: 0; + gap: 8px; +} + +.robot-status-facts > div { + display: grid; + align-items: start; + gap: 10px; + grid-template-columns: 42px minmax(0, 1fr); +} + +.robot-status-facts dt, +.robot-status-facts dd { + margin: 0; + font-size: 0.72rem; + line-height: 1.4; +} + +.robot-status-facts dt { + color: var(--muted); +} + +.robot-status-facts dd { + overflow-wrap: anywhere; + color: #303733; + font-weight: 650; +} + +.robot-status-snapshot { + margin-top: 12px; + color: var(--faint); +} + +.stage-actions { + display: flex; + margin-top: 30px; + align-items: center; + justify-content: space-between; + gap: 14px; + border-top: 1px solid var(--line); + padding-top: 22px; +} + +.stage-actions-end { + margin-left: auto; +} + +.robot-context { + margin-bottom: 18px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px 16px; + background: var(--surface); +} + +.robot-context-main, +.robot-context-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.robot-avatar { + display: grid; + width: 38px; + height: 38px; + flex: 0 0 auto; + place-items: center; + border-radius: 10px; + color: #063c2e; + background: #dffff5; + font-size: 0.9rem; + font-weight: 850; +} + +.robot-context strong, +.review-robot strong, +.run-hero strong { + display: block; + color: var(--ink); + font-size: 0.9rem; +} + +.resource-notice { + display: flex; + align-items: center; + gap: 12px; +} + +.resource-notice .inline-notice { + flex: 1; +} + +.sequence-list { + display: grid; + margin: 0; + padding: 0; + gap: 12px; + list-style: none; +} + +.sequence-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); +} + +.sequence-card.dragging { + opacity: 0.48; +} + +.drag-handle { + display: grid; + width: 42px; + min-height: 100%; + place-items: start center; + border: 0; + padding-top: 22px; + color: #a0a8a4; + background: transparent; + font-size: 1rem; + cursor: grab; +} + +.drag-handle:disabled { + cursor: default; +} + +.sequence-content { + min-width: 0; + padding: 18px 0 20px; +} + +.sequence-heading { + display: flex; + margin-bottom: 16px; + align-items: center; + gap: 10px; +} + +.sequence-number { + display: inline-grid; + width: 24px; + height: 24px; + place-items: center; + border-radius: 7px; + color: #08684e; + background: #e8fff8; + font-size: 0.7rem; + font-weight: 850; +} + +.sequence-heading h2 { + margin: 0; + color: var(--ink); + font-size: 0.96rem; +} + +.action-menu-wrap { + position: relative; + padding: 14px 12px 0 8px; +} + +.icon-button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid transparent; + border-radius: 8px; + color: #68716c; + background: transparent; + cursor: pointer; +} + +.icon-button:hover:not(:disabled) { + border-color: var(--line); + background: var(--surface-soft); +} + +.icon-button:disabled { + color: #b8bfbb; + cursor: default; +} + +.action-menu { + position: absolute; + z-index: 10; + top: 48px; + right: 12px; + display: grid; + width: 142px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 10px; + padding: 5px; + background: var(--surface); + box-shadow: 0 12px 32px rgb(17 21 19 / 12%); +} + +.action-menu button { + border: 0; + border-radius: 7px; + padding: 8px 10px; + text-align: left; + background: transparent; + font-size: 0.78rem; + cursor: pointer; +} + +.action-menu button:hover:not(:disabled) { + background: #f2f5f3; +} + +.action-menu button:disabled { + color: #b0b7b3; + cursor: not-allowed; +} + +.action-menu button.danger-text { + color: var(--danger); +} + +.add-action { + position: relative; + display: flex; + margin-top: 14px; + justify-content: center; +} + +.add-action-menu { + position: absolute; + z-index: 10; + top: 50px; + left: 50%; + display: grid; + width: min(360px, calc(100vw - 48px)); + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + border: 1px solid var(--line); + border-radius: 12px; + padding: 7px; + background: var(--surface); + box-shadow: 0 16px 40px rgb(17 21 19 / 13%); + transform: translateX(-50%); +} + +.add-action-menu button { + display: grid; + min-height: 74px; + place-items: center; + gap: 5px; + border: 0; + border-radius: 8px; + padding: 10px; + color: #343b37; + background: transparent; + font-size: 0.76rem; + font-weight: 720; + cursor: pointer; +} + +.add-action-menu button:hover:not(:disabled) { + background: #effbf7; +} + +.add-action-menu button:disabled { + color: #a3aaa6; + cursor: not-allowed; +} + +.action-glyph { + font-size: 1rem; +} + +.empty-sequence { + display: grid; + min-height: 180px; + place-items: center; + border: 1px dashed var(--line-strong); + border-radius: 12px; + color: var(--muted); + background: rgb(255 255 255 / 44%); + text-align: center; +} + +.empty-sequence strong { + display: block; + margin-bottom: 6px; + color: #3d4541; +} + +.contact-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + border: 0; + margin: 0; + padding: 0; +} + +.contact-choice input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; +} + +.contact-choice span { + display: inline-flex; + min-height: 38px; + align-items: center; + border: 1px solid var(--line-strong); + border-radius: 9px; + padding: 8px 12px; + color: #48504c; + background: #fff; + font-size: 0.8rem; + cursor: pointer; +} + +.contact-choice input:checked + span { + border-color: var(--brand); + color: #09664d; + background: #effcf8; +} + +.contact-options:disabled .contact-choice span { + color: #969e9a; + background: #f3f5f4; + cursor: not-allowed; +} + +.review-panel, +.run-panel { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface); +} + +.review-robot, +.run-hero { + padding: 20px 22px; + border-bottom: 1px solid var(--line); +} + +.review-list, +.run-list { + margin: 0; + padding: 4px 22px; + list-style: none; +} + +.review-item, +.run-item { + display: grid; + min-height: 64px; + align-items: center; + gap: 14px; + border-bottom: 1px solid #edf0ee; + grid-template-columns: 28px minmax(0, 1fr) auto; +} + +.review-item:last-child, +.run-item:last-child { + border-bottom: 0; +} + +.review-item strong, +.run-item strong { + display: block; + color: #252b27; + font-size: 0.85rem; +} + +.review-item p, +.run-item p { + margin: 4px 0 0; + overflow-wrap: anywhere; + color: var(--muted); + font-size: 0.76rem; +} + +.status-pill { + display: inline-flex; + min-height: 26px; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 9px; + color: #55605a; + background: #fafbfa; + font-size: 0.7rem; + font-weight: 760; + white-space: nowrap; +} + +.status-pill.online { + border-color: #aee9d7; + color: #087255; + background: #effcf8; +} + +.status-pill.busy { + border-color: #ead8a5; + color: var(--warning); + background: var(--warning-soft); +} + +.review-check { + display: flex; + margin-top: 14px; + align-items: center; + gap: 12px; + border: 1px solid #b9eadb; + border-radius: 11px; + padding: 14px 16px; + color: #075b45; + background: #f2fff9; + font-size: 0.82rem; + font-weight: 680; +} + +.review-check.neutral { + border-color: var(--line); + color: #4f5954; + background: var(--surface); +} + +.review-check.blocked { + border-color: #ead8a5; + color: #71531b; + background: var(--warning-soft); +} + +.check-mark, +.run-mark { + display: inline-grid; + width: 28px; + height: 28px; + flex: 0 0 auto; + place-items: center; + border-radius: 999px; + color: #075b45; + background: #c9f5e7; + font-weight: 850; +} + +.review-check.blocked .check-mark { + color: #765315; + background: #f6e5b7; +} + +.run-status { + display: inline-flex; + align-items: center; + gap: 8px; + color: #087255; + font-size: 0.82rem; + font-weight: 760; +} + +.pulse { + width: 9px; + height: 9px; + border-radius: 999px; + background: var(--brand); + box-shadow: 0 0 0 0 rgb(33 205 153 / 35%); + animation: pulse 1.8s infinite; +} + +@keyframes pulse { + 70% { + box-shadow: 0 0 0 7px rgb(33 205 153 / 0%); + } + 100% { + box-shadow: 0 0 0 0 rgb(33 205 153 / 0%); + } +} + +.run-item .run-mark { + width: 26px; + height: 26px; + color: #7d8782; + background: #eef1ef; + font-size: 0.72rem; +} + +.run-item.active .run-mark { + color: #075b45; + background: #c9f5e7; +} + +.run-item.complete .run-mark { + color: #fff; + background: #178665; +} + +.run-item.waiting { + opacity: 0.62; +} + +.modal-backdrop { + position: fixed; + z-index: 80; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgb(12 16 14 / 48%); +} + +.modal { + width: min(100%, 510px); + border: 1px solid var(--line); + border-radius: 14px; + padding: 26px; + background: var(--surface); + box-shadow: 0 24px 72px rgb(5 9 7 / 22%); +} + +.modal h2 { + font-size: 1.55rem; +} + +.modal-summary { + display: grid; + margin: 22px 0 0; + gap: 0; + border: 1px solid var(--line); + border-radius: 10px; + padding: 3px 14px; +} + +.modal-summary > div { + display: flex; + min-height: 46px; + align-items: center; + justify-content: space-between; + gap: 18px; + border-bottom: 1px solid #edf0ee; +} + +.modal-summary > div:last-child { + border-bottom: 0; +} + +.modal-summary span { + color: var(--muted); + font-size: 0.78rem; +} + +.modal-summary strong { + color: #2a312d; + font-size: 0.8rem; + overflow-wrap: anywhere; + text-align: right; +} + +.safety-note { + margin: 14px 0 0; + border-left: 3px solid #d5a12e; + padding: 7px 0 7px 12px; + color: #6d5118; + font-size: 0.8rem; + line-height: 1.5; +} + +.modal-actions { + display: flex; + margin-top: 24px; + justify-content: flex-end; + gap: 10px; +} + +@media (max-width: 760px) { + .header-inner, + .app-main { + width: min(100% - 28px, 960px); + } + + .brand-lockup img { + width: 120px; + } + + .header-button, + .connection-status span:not(.status-dot) { + display: none; + } + + .app-main { + padding-top: 34px; + } + + .progress { + margin-bottom: 36px; + } + + .progress-step { + gap: 7px; + font-size: 0.72rem; + } + + .robot-grid { + grid-template-columns: 1fr; + } + + .robot-card { + min-height: 122px; + } + + .stage-head { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .robot-list-tools { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } + + .stage-actions { + align-items: stretch; + } + + .stage-actions .button { + flex: 1; + } + + .sequence-card { + grid-template-columns: 32px minmax(0, 1fr) 44px; + } + + .drag-handle { + width: 32px; + } +} + +@media (max-width: 520px) { + .header-inner { + min-height: 60px; + } + + .brand-lockup img { + width: 105px; + } + + .app-main { + padding-top: 26px; + } + + .progress-label { + display: none; + } + + .connect-wrap { + align-items: start; + min-height: auto; + } + + .connect-card { + border-radius: 12px; + padding: 24px 20px; + } + + .stage-head h1 { + font-size: 1.9rem; + } + + .robot-context, + .review-robot, + .run-hero { + align-items: flex-start; + flex-direction: column; + } + + .robot-context-actions { + width: 100%; + justify-content: space-between; + } + + .resource-notice { + align-items: flex-start; + flex-direction: column; + } + + .sequence-content { + padding-right: 4px; + } + + .add-action-menu { + grid-template-columns: 1fr; + } + + .add-action-menu button { + min-height: 46px; + grid-template-columns: 24px 1fr; + justify-items: start; + } + + .review-item, + .run-item { + grid-template-columns: 28px minmax(0, 1fr); + } + + .review-item > :last-child, + .run-item > :last-child { + display: none; + } + + .modal { + padding: 22px 18px; + } + + .modal-actions { + align-items: stretch; + flex-direction: column-reverse; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/demos/temi-openapi-scenario-runner/src/timeline.ts b/demos/temi-openapi-scenario-runner/src/timeline.ts new file mode 100644 index 0000000..0191ed5 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/src/timeline.ts @@ -0,0 +1,444 @@ +import { isAbortError, TemiApiError, type ErrorCode, type ValidationHint } from "./api"; +import type { RunState } from "./run-state"; + +export const API_OPERATION_CONTRACTS = { + verify: { method: "GET", path: "/verify", sideEffect: "read" }, + listRobots: { method: "GET", path: "/robots", sideEffect: "read" }, + getRobotStatus: { method: "GET", path: "/robots/{serialNumber}", sideEffect: "read" }, + listRobotLocations: { + method: "GET", + path: "/robots/{serialNumber}/locations", + sideEffect: "read", + }, + listRobotContacts: { + method: "GET", + path: "/robots/{serialNumber}/contacts", + sideEffect: "read", + }, + validateSequence: { method: "POST", path: "/sequences/validate", sideEffect: "read" }, + playSequence: { method: "POST", path: "/sequences/play", sideEffect: "write" }, + stopSequence: { method: "POST", path: "/sequences/stop", sideEffect: "write" }, +} as const; + +export type ApiOperation = keyof typeof API_OPERATION_CONTRACTS; +export type TimelineOutcome = + | "queued" + | "pending" + | "succeeded" + | "failed" + | "unknown" + | "cancelled"; + +export type TimelineObservation = + | "completed" + | "aborted" + | "stopped" + | "observation_unknown" + | "identity_mismatch"; + +export type SafeSummaryValue = + | string + | number + | boolean + | readonly (string | number)[]; +export type SafeSummary = Readonly>; + +export type TimelineErrorKind = + | "unauthorized" + | "http" + | "network" + | "timeout" + | "aborted" + | "invalid_response" + | "unknown"; + +export type SafeErrorSummary = Readonly<{ + kind: TimelineErrorKind; + status: number | null; + code: ErrorCode | null; + validationHint: ValidationHint | null; +}>; + +export type TimelineEvent = Readonly<{ + id: number; + phase: RunState; + operation: ApiOperation; + method: "GET" | "POST"; + path: string; + requestSummary: SafeSummary; + startedAt: number | null; + endedAt: number | null; + durationMs: number | null; + httpStatus: number | null; + outcome: TimelineOutcome; + responseSummary: SafeSummary | null; + errorSummary: SafeErrorSummary | null; + explanation: string; +}>; + +export type TimelineEnqueueInput = Readonly<{ + phase: RunState; + operation: ApiOperation; + requestSummary?: unknown; +}>; + +export type TimelineCompletionInput = Readonly<{ + outcome: Exclude; + httpStatus?: number | null; + response?: unknown; + error?: unknown; +}>; + +export type TimelineAnnotation = Readonly<{ + observation?: TimelineObservation; + explanation?: string; +}>; + +type MutableTimelineEvent = { + id: number; + phase: RunState; + operation: ApiOperation; + method: "GET" | "POST"; + path: string; + requestSummary: SafeSummary; + startedAt: number | null; + endedAt: number | null; + durationMs: number | null; + httpStatus: number | null; + outcome: TimelineOutcome; + responseSummary: SafeSummary | null; + errorSummary: SafeErrorSummary | null; + explanation: string; +}; + +export class TimelineStore { + #events: MutableTimelineEvent[] = []; + #nextId = 1; + #now: () => number; + + constructor(now: () => number = () => Date.now()) { + this.#now = now; + } + + enqueue(input: TimelineEnqueueInput): number { + const contract = API_OPERATION_CONTRACTS[input.operation]; + const event: MutableTimelineEvent = { + id: this.#nextId++, + phase: input.phase, + operation: input.operation, + method: contract.method, + path: contract.path, + requestSummary: sanitizeRequestSummary(input.operation, input.requestSummary), + startedAt: null, + endedAt: null, + durationMs: null, + httpStatus: null, + outcome: "queued", + responseSummary: null, + errorSummary: null, + explanation: "Request queued.", + }; + this.#events.push(event); + return event.id; + } + + start(id: number): void { + const event = this.find(id); + if (event.outcome !== "queued") { + throw new Error(`Timeline event ${id} is already started.`); + } + event.startedAt = this.#now(); + event.outcome = "pending"; + event.explanation = "Request is pending."; + } + + complete(id: number, input: TimelineCompletionInput): void { + const event = this.find(id); + if (event.startedAt === null || event.endedAt !== null) { + throw new Error(`Timeline event ${id} is not pending.`); + } + + event.endedAt = this.#now(); + event.durationMs = Math.max(0, event.endedAt - event.startedAt); + event.httpStatus = safeHttpStatus(input.httpStatus); + event.outcome = input.outcome; + event.responseSummary = + input.response === undefined + ? null + : sanitizeResponseSummary(event.operation, input.response); + event.errorSummary = input.error === undefined ? null : sanitizeErrorSummary(input.error); + event.explanation = explainOutcome( + event.operation, + event.outcome, + event.httpStatus, + event.responseSummary, + ); + } + + annotate(id: number, input: TimelineAnnotation): void { + const event = this.find(id); + if (event.endedAt === null) { + throw new Error(`Timeline event ${id} is not complete.`); + } + if (input.observation !== undefined) { + event.responseSummary = freezeSummary({ + ...(event.responseSummary ?? {}), + observation: input.observation, + }); + } + if (input.explanation !== undefined) { + event.explanation = input.explanation; + } + } + + has(id: number): boolean { + return this.#events.some((event) => event.id === id); + } + + clear(): void { + this.#events = []; + } + + getEvents(): readonly TimelineEvent[] { + return this.#events.map(copyEvent); + } + + private find(id: number): MutableTimelineEvent { + const event = this.#events.find((candidate) => candidate.id === id); + if (!event) { + throw new Error(`Timeline event ${id} does not exist.`); + } + return event; + } +} + +export function sanitizeRequestSummary(operation: ApiOperation, input: unknown = {}): SafeSummary { + const record = asRecord(input); + if (operation === "validateSequence" || operation === "playSequence") { + const actions = Array.isArray(record.actions) ? record.actions : []; + const actionRecords = actions.map(asRecord); + return freezeSummary({ + actionCount: actions.length, + actionTypes: actionRecords.flatMap((action) => + isSequenceActionType(action.type) ? [action.type] : [], + ), + steps: actionRecords.flatMap((action) => isStep(action.step) ? [action.step] : []), + }); + } + + if (operation === "stopSequence") { + return freezeSummary({ sequenceIdPresent: hasNonEmptyString(record.sequenceId) }); + } + + if ( + operation === "getRobotStatus" || + operation === "listRobotLocations" || + operation === "listRobotContacts" + ) { + return freezeSummary({ serialNumberPresent: hasNonEmptyString(record.serialNumber) }); + } + + return freezeSummary({}); +} + +export function sanitizeResponseSummary(operation: ApiOperation, input: unknown): SafeSummary { + const record = asRecord(input); + if (operation === "listRobots") { + return freezeSummary({ robotCount: arrayLength(record.robots) }); + } + if (operation === "listRobotLocations") { + return freezeSummary({ locationCount: arrayLength(record.locations) }); + } + if (operation === "listRobotContacts") { + return freezeSummary({ contactCount: arrayLength(record.contacts) }); + } + if (operation === "getRobotStatus") { + const sequenceKnown = record.sequence !== undefined && record.sequence !== null; + const sequence = asRecord(record.sequence); + return freezeSummary({ + status: safeStatus(record.status) ?? "unknown / unavailable", + movementKnown: record.movement !== undefined && record.movement !== null, + sequenceKnown, + sequenceStatus: sequenceKnown ? safeStatus(sequence.status) ?? "unknown / unavailable" : "absent", + callKnown: record.call !== undefined && record.call !== null, + batteryKnown: record.battery !== undefined && record.battery !== null, + }); + } + if (operation === "verify") { + return freezeSummary({ + status: safeStatus(record.status) ?? "unknown / unavailable", + scopeCount: arrayLength(record.scopes), + robotScope: safeRobotScope(record.robotScope) ?? "unknown / unavailable", + selectedRobotCount: arrayLength(record.serialNumbers), + }); + } + if (operation === "validateSequence") { + return optionalSummary({ status: safeStatus(record.status) }); + } + return freezeSummary({ sequenceIdPresent: hasNonEmptyString(record.sequenceId) }); +} + +export function sanitizeErrorSummary(error: unknown): SafeErrorSummary { + if (error instanceof TemiApiError) { + return freezeErrorSummary({ + kind: error.kind, + status: safeHttpStatus(error.status), + code: error.code, + validationHint: error.validationHint, + }); + } + + if (isAbortError(error)) { + return freezeErrorSummary({ kind: "aborted", status: null, code: null, validationHint: null }); + } + + return freezeErrorSummary({ kind: "unknown", status: null, code: null, validationHint: null }); +} + +function explainOutcome( + operation: ApiOperation, + outcome: Exclude, + status: number | null, + response: SafeSummary | null, +): string { + if (outcome === "succeeded") { + switch (operation) { + case "verify": + return "verify succeeded; continue with GET /robots."; + case "listRobots": + if (response?.robotCount === 0) { + return "robots returned no accessible PRO robots; no hardware serialNumber can be selected and no downstream reads will start."; + } + return "robots succeeded; choose a hardware serialNumber to read status, locations, and contacts in parallel."; + case "getRobotStatus": + return "status succeeded; only online enters Play preparation. Missing facts stay unknown/unavailable; this is last-known, not realtime."; + case "listRobotLocations": + if (response?.locationCount === 0) { + return "locations returned no selectable items; MOVEMENT remains unavailable."; + } + return "locations succeeded; these choices can enable MOVEMENT only."; + case "listRobotContacts": + if (response?.contactCount === 0) { + return "contacts returned no selectable items; START_CALL remains unavailable."; + } + return "contacts succeeded; these choices can enable START_CALL only."; + default: + return `${operation} completed.`; + } + } + if (outcome === "failed") { + const rejection = status === null + ? `${operation} failed with a known request error.` + : `${operation} was rejected with HTTP ${status}.`; + switch (operation) { + case "verify": + return `${rejection} No GET /robots request will be sent.`; + case "listRobots": + return `${rejection} Robot selection and the three robot reads cannot start.`; + case "getRobotStatus": + return `${rejection} Play preparation stays blocked; the operator can refresh status.`; + case "listRobotLocations": + return `${rejection} MOVEMENT is disabled; SPEAK and successful contacts are unaffected.`; + case "listRobotContacts": + return `${rejection} START_CALL is disabled; SPEAK and successful locations are unaffected.`; + default: + return rejection; + } + } + if (outcome === "cancelled") return `${operation} was cancelled before completion.`; + return `${operation} may have had a side effect; the result is not confirmed.`; +} + +function copyEvent(event: MutableTimelineEvent): TimelineEvent { + return Object.freeze({ + ...event, + requestSummary: cloneSummary(event.requestSummary), + responseSummary: event.responseSummary === null ? null : cloneSummary(event.responseSummary), + errorSummary: event.errorSummary === null ? null : Object.freeze({ ...event.errorSummary }), + }); +} + +function cloneSummary(summary: SafeSummary): SafeSummary { + return Object.freeze( + Object.fromEntries( + Object.entries(summary).map(([key, value]) => [ + key, + Array.isArray(value) ? Object.freeze([...value]) : value, + ]), + ), + ); +} + +function freezeSummary(summary: Record): SafeSummary { + return cloneSummary(summary); +} + +function optionalSummary(values: Record): SafeSummary { + return freezeSummary( + Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined)) as Record< + string, + SafeSummaryValue + >, + ); +} + +function freezeErrorSummary(summary: SafeErrorSummary): SafeErrorSummary { + return Object.freeze(summary); +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function arrayLength(value: unknown): number { + return Array.isArray(value) ? value.length : 0; +} + +function hasNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function isSequenceActionType(value: unknown): value is "MOVEMENT" | "SPEAK" | "START_CALL" { + return value === "MOVEMENT" || value === "SPEAK" || value === "START_CALL"; +} + +function isStep(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 1; +} + +function safeStatus(value: unknown): string | undefined { + if ( + value === "ok" || + value === "success" || + value === "online" || + value === "offline" || + value === "busy" || + value === "privacy" || + value === "start" || + value === "running" || + value === "succeeded" || + value === "complete" || + value === "completed" || + value === "abort" || + value === "stop" || + value === "idle" || + value === "stopped" || + value === "aborted" || + value === "failed" || + value === "cancelled" || + value === "canceled" + ) { + return value; + } + return undefined; +} + +function safeRobotScope(value: unknown): "all" | "selected" | undefined { + return value === "all" || value === "selected" ? value : undefined; +} + +function safeHttpStatus(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599 + ? value + : null; +} diff --git a/demos/temi-openapi-scenario-runner/tsconfig.json b/demos/temi-openapi-scenario-runner/tsconfig.json new file mode 100644 index 0000000..6d091a4 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2022", "DOM"], + "types": ["vite/client", "node"] + }, + "include": ["src/**/*.ts", "vite.config.mts"] +} diff --git a/demos/temi-openapi-scenario-runner/vite.config.mts b/demos/temi-openapi-scenario-runner/vite.config.mts new file mode 100644 index 0000000..6459261 --- /dev/null +++ b/demos/temi-openapi-scenario-runner/vite.config.mts @@ -0,0 +1,16 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vite"; + +const demoRoot = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root: demoRoot, + base: "./", + build: { + outDir: resolve(demoRoot, "dist"), + emptyOutDir: true, + sourcemap: false, + }, +}); From 8e1d805dc4afc719a05faaede8eac571746b3b2b Mon Sep 17 00:00:00 2001 From: SPINN Date: Mon, 31 Aug 2026 15:29:43 +0800 Subject: [PATCH 2/2] Prevent robot card controls from overflowing --- demos/temi-openapi-scenario-runner/src/styles.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/demos/temi-openapi-scenario-runner/src/styles.css b/demos/temi-openapi-scenario-runner/src/styles.css index 8ec14e6..51ad469 100644 --- a/demos/temi-openapi-scenario-runner/src/styles.css +++ b/demos/temi-openapi-scenario-runner/src/styles.css @@ -663,6 +663,7 @@ button { .robot-select-control { display: grid; width: 100%; + min-width: 0; border: 0; padding: 0; gap: 16px; @@ -683,6 +684,7 @@ button { .robot-card-top { width: 100%; + min-width: 0; } .robot-card-top > span:first-child { @@ -779,6 +781,7 @@ button { .robot-card-footer { display: flex; + min-width: 0; align-items: center; justify-content: space-between; gap: 12px;