diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index b6ee18530..d63d3ff70 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -61,3 +61,30 @@ Return invoice_id, customer, due_date, amount, status, and detail_url. Pause if need to sign in, never ask me to paste credentials into chat, and do not change any records. ``` + +## Run a Natural-Language Workflow + +For direct CLI use, configure Gemini in the shell without putting the key in the +repository: + +```powershell +$env:GEMINI_API_KEY = "your-new-key" +``` + +Then provide the goal. Webcmd asks Gemini for a validated workflow, creates a +browser Session, runs the browser steps, and carries returned values between +steps: + +```powershell +npm run dev -- workflow run --goal "Find the OB Fitness Challenge on Site A and submit its details on Site B" +``` + +Inspect the generated workflow without opening a browser: + +```powershell +npm run dev -- workflow run --goal "Find the OB Fitness Challenge on Site A and submit its details on Site B" --plan-only -f json +``` + +Use `--session ` when continuing an existing authenticated Session, and use +`--keep-session` when the automatically created Session should remain available +after the workflow finishes. diff --git a/package-lock.json b/package-lock.json index 6263deb9c..0fa99375d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "@google/genai": "^2.10.0", "@mozilla/readability": "^0.6.0", "cli-table3": "^0.6.5", "cloakbrowser": "0.4.5", @@ -31,7 +32,6 @@ "devDependencies": { "@emnapi/core": "^1.11.2", "@emnapi/runtime": "^1.11.2", - "@google/genai": "^2.10.0", "@types/js-yaml": "^4.0.9", "@types/jsdom": "^27.0.0", "@types/node": "^25.5.2", @@ -746,7 +746,6 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.10.0.tgz", "integrity": "sha512-e4cFxj3tiuMtsgOT4G9c1hXyGJhg7/Buj7VVeBacRY3fRtkRZZ59Q3nuVp2xbq8BGQXLXCDB253qMhklMOeUDg==", - "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -879,35 +878,30 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1" @@ -917,28 +911,24 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { @@ -1331,7 +1321,6 @@ "version": "25.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -1341,7 +1330,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "dev": true, "license": "MIT" }, "node_modules/@types/tough-cookie": { @@ -1485,7 +1473,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -1520,7 +1507,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -1550,7 +1536,6 @@ "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -1560,7 +1545,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/chai": { @@ -1665,7 +1649,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -1688,7 +1671,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1722,7 +1704,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -1807,7 +1788,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, "license": "MIT" }, "node_modules/fdir": { @@ -1832,7 +1812,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, "funding": [ { "type": "github", @@ -1856,7 +1835,6 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -1884,7 +1862,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -1899,7 +1876,6 @@ "version": "8.1.2", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "gaxios": "^7.0.0", @@ -1914,7 +1890,6 @@ "version": "10.9.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -1932,7 +1907,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" @@ -1954,7 +1928,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -2201,7 +2174,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "dev": true, "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -2211,7 +2183,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, "license": "MIT", "dependencies": { "buffer-equal-constant-time": "^1.0.1", @@ -2223,7 +2194,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, "license": "MIT", "dependencies": { "jwa": "^2.0.1", @@ -2495,7 +2465,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/lru-cache": { @@ -2548,7 +2517,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2575,7 +2543,6 @@ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", - "dev": true, "funding": [ { "type": "github", @@ -2595,7 +2562,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -2625,7 +2591,6 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/retry": "0.12.0", @@ -2733,7 +2698,6 @@ "version": "7.6.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", - "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -2800,7 +2764,6 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -2844,7 +2807,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -3106,7 +3068,6 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, "license": "MIT" }, "node_modules/vite": { @@ -3294,7 +3255,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" diff --git a/package.json b/package.json index 8e157c60f..ac718882b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "./download/progress": "./dist/src/download/progress.js", "./fetch/command": "./dist/src/fetch/command.js", "./pipeline": "./dist/src/pipeline/index.js", + "./planner": "./dist/src/planner/index.js", + "./planner/gemini": "./dist/src/planner/gemini.js", "./plugin-runtime": "./dist/src/plugin-runtime.js", "./hosted/availability": "./dist/src/hosted/availability.js", "./hosted/core-commands": "./dist/src/hosted/core-commands.js", @@ -53,6 +55,7 @@ "scripts": { "benchmark": "uv run python benchmarks/scripts/run_eval.py", "dev": "tsx src/main.ts", + "ui": "tsx src/workflow-ui-server.ts", "dev:bun": "bun src/main.ts", "build": "npm run clean-dist && npm run copy-yaml && npm run compile && npm run build-manifest", "compile": "tsc --build && node -e \"require('fs').chmodSync('dist/src/main.js', 0o755)\"", @@ -96,6 +99,7 @@ "access": "public" }, "dependencies": { + "@google/genai": "^2.10.0", "@mozilla/readability": "^0.6.0", "cli-table3": "^0.6.5", "cloakbrowser": "0.4.5", @@ -114,7 +118,6 @@ "devDependencies": { "@emnapi/core": "^1.11.2", "@emnapi/runtime": "^1.11.2", - "@google/genai": "^2.10.0", "@types/js-yaml": "^4.0.9", "@types/jsdom": "^27.0.0", "@types/node": "^25.5.2", diff --git a/src/cli.ts b/src/cli.ts index 0ae61635b..b327c2515 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -66,6 +66,8 @@ import { readOverrideRecords, removeOverrideRecords } from './override-provenanc import { clearDaemonRunContext, generateRunId, isUnknownOutcomeError, runWithDaemonRunContext } from './session-lease.js'; import { createLocalLearningBackend, createLocalSiteMemoryBackend, registerSiteCommands } from './site-memory/commands.js'; import { resolveAdapterSourcePath, splitAdapterCommandKey } from './adapter-source.js'; +import { planWithGemini } from './planner/gemini.js'; +import { runBrowserWorkflow } from './planner/index.js'; const CLI_FILE = fileURLToPath(import.meta.url); const FOLLOW_POLL_MS = 1_000; @@ -661,6 +663,58 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; }); + const workflowRunCmd = addOutputFormatOption(program + .command('workflow') + .description('Plan and execute a natural-language browser workflow') + .command('run') + .description('Ask Gemini to plan a workflow and run it in a browser Session') + .requiredOption('--goal ', 'Natural-language task to perform') + .option('--session ', 'Existing browser Session ID; created automatically when omitted') + .option('--profile ', 'Browser profile to use', 'default') + .option('--model ', 'Gemini model name') + .option('--plan-only', 'Print the validated plan without opening a browser') + .option('--keep-session', 'Keep an automatically created Session open after completion')); + workflowRunCmd.action(async (opts) => { + const fmt = resolveCommandOutputFormat(workflowRunCmd, opts.format); + if (fmt === null) return; + try { + const workflow = await planWithGemini(opts.goal, { model: opts.model }); + if (opts.planOnly) { + await renderOutput(workflow, { fmt, fmtExplicit: outputFormatIsExplicit(workflowRunCmd), title: 'webcmd/workflow/plan', source: 'webcmd workflow run' }); + return; + } + const profile = resolveProfileSelection(opts.profile); + const profileId = profile?.contextId ?? 'default'; + let session = opts.session?.trim(); + let created = false; + if (!session) { + const createdData = await sendCommand('session-create', { contextId: profileId, sessionName: 'workflow' }); + if (!createdData || typeof createdData !== 'object' || typeof (createdData as { id?: unknown }).id !== 'string') { + throw new CliError('SESSION_CREATE_FAILED', 'The browser daemon did not return a usable workflow Session ID.'); + } + session = (createdData as { id: string }).id; + created = true; + } + try { + const result = await runBrowserWorkflow(workflow, { session, profile: opts.profile, context: {} }); + await renderOutput({ session, ...result }, { fmt, fmtExplicit: outputFormatIsExplicit(workflowRunCmd), title: 'webcmd/workflow/result', source: 'webcmd workflow run' }); + } finally { + if (created && !opts.keepSession) { + try { + await sendCommand('session-close', { contextId: profileId, session, force: true, discard: true }); + } catch { + // Browser runtime cleanup is best effort after a workflow failure. + } + } + } + } catch (error) { + const message = error instanceof CliError ? `${error.code}: ${error.message}` : getErrorMessage(error); + if (error instanceof CliError && error.hint) console.error(`${message}\n${error.hint}`); + else console.error(message); + process.exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.GENERIC_ERROR; + } + }); + // Bare `skills` and `skills list` render the same rows; the only difference is // the invocation reported in the table footer. const renderSkillsList = (fmt: string, fmtExplicit: boolean, source: string): Promise => diff --git a/src/planner/gemini.ts b/src/planner/gemini.ts new file mode 100644 index 000000000..b362101be --- /dev/null +++ b/src/planner/gemini.ts @@ -0,0 +1,65 @@ +import { GoogleGenAI } from '@google/genai'; +import { ConfigError } from '../errors.js'; +import { validateWorkflow, type Workflow } from './index.js'; + +export interface GeminiPlannerOptions { + apiKey?: string; + model?: string; + client?: Pick; +} + +const WORKFLOW_SCHEMA = { + type: 'object', + properties: { + goal: { type: 'string' }, + steps: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + site: { type: 'string' }, + action: { type: 'string', enum: ['navigate', 'search', 'extract', 'fill', 'click', 'submit', 'command'] }, + command: { type: 'string' }, + args: { type: 'object' }, + dependsOn: { type: 'array', items: { type: 'string' } }, + outputs: { type: 'object' }, + script: { type: 'string' }, + }, + required: ['id', 'site', 'action', 'command'], + }, + }, + }, + required: ['goal', 'steps'], +} as const; + +export async function planWithGemini(goal: string, options: GeminiPlannerOptions = {}): Promise { + const apiKey = options.apiKey ?? process.env.GEMINI_API_KEY; + if (!apiKey && !options.client) { + throw new ConfigError('GEMINI_API_KEY is not configured.', 'Set GEMINI_API_KEY in the local environment and retry.'); + } + const client = options.client ?? new GoogleGenAI({ apiKey: apiKey! }); + const response = await client.models.generateContent({ + model: options.model ?? process.env.GEMINI_MODEL ?? 'gemini-2.5-flash', + contents: [ + 'Create an executable Webcmd browser workflow for this user goal:\n\n', + goal, + '\n\nReturn only JSON matching the supplied schema. Use one or more steps per website. Prefer a sandboxed Playwright script in `script` for browser actions. Each script must return JSON-serializable data. Use `workflowContext` to consume values extracted by earlier steps. Never invent credentials, and mark external writes as submit actions.', + ].join(''), + config: { + responseMimeType: 'application/json', + responseSchema: WORKFLOW_SCHEMA, + systemInstruction: 'You are a careful browser workflow planner. Produce only validated, ordered, executable steps. A step that consumes context must depend on the producing step.', + }, + }); + const text = response.text?.trim(); + if (!text) throw new ConfigError('Gemini returned an empty workflow.', 'Retry the goal or check Gemini availability.'); + let workflow: Workflow; + try { + workflow = JSON.parse(text) as Workflow; + } catch { + throw new ConfigError('Gemini returned invalid workflow JSON.', 'Retry the goal; the model response must be JSON matching the workflow schema.'); + } + validateWorkflow(workflow); + return workflow; +} diff --git a/src/planner/index.test.ts b/src/planner/index.test.ts new file mode 100644 index 000000000..a77cf5a2b --- /dev/null +++ b/src/planner/index.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cli, getRegistry } from '../registry.js'; +import { + executeWorkflow, + planNaturalLanguageGoal, + validateWorkflow, + type Workflow, +} from './index.js'; +import { planWithGemini } from './gemini.js'; + +const registered = [ + 'p3-source/search', + 'p3-source/extract', + 'p3-target/navigate', + 'p3-target/fill', + 'p3-target/submit', +]; + +afterEach(() => { + for (const name of registered) getRegistry().delete(name); +}); + +describe('cross-site planner', () => { + it('plans a natural-language goal and passes extracted Site A data to Site B', async () => { + const fills: Array> = []; + const submitted: Array> = []; + cli({ site: 'p3-source', name: 'search', browser: false, access: 'read', description: '', args: [], func: async () => ({ result: 'event-42' }) }); + cli({ + site: 'p3-source', name: 'extract', browser: false, access: 'read', description: '', args: [], + func: async args => ({ name: 'OB Fitness Challenge', date: '2026-10-04', location: 'Old Barracks', received: args.result }), + }); + cli({ site: 'p3-target', name: 'navigate', browser: false, access: 'read', description: '', args: [], func: async () => ({ navigated: true }) }); + cli({ site: 'p3-target', name: 'fill', browser: false, access: 'write', description: '', args: [], func: async args => { fills.push(args); return { filled: true }; } }); + cli({ site: 'p3-target', name: 'submit', browser: false, access: 'write', description: '', args: [], func: async args => { submitted.push(args); return { submitted: true }; } }); + + const workflow = planNaturalLanguageGoal( + 'Find the OB Fitness Challenge on Site A and submit its details on Site B.', + { + commands: { + source: { search: 'p3-source/search', extract: 'p3-source/extract' }, + target: { navigate: 'p3-target/navigate', fill: 'p3-target/fill', submit: 'p3-target/submit' }, + }, + }, + ); + const execution = await executeWorkflow(workflow); + + expect(execution.context).toMatchObject({ + search: { result: 'event-42' }, + event: { name: 'OB Fitness Challenge', date: '2026-10-04', location: 'Old Barracks' }, + }); + expect(fills).toEqual([ + { field: 'name', value: 'OB Fitness Challenge' }, + { field: 'date', value: '2026-10-04' }, + { field: 'location', value: 'Old Barracks' }, + ]); + expect(submitted).toEqual([{}]); + }); + + it('rejects a missing extracted value before execution', () => { + const workflow = baseWorkflow({ outputs: { 'event.name': 'name' } }); + expect(() => validateWorkflow(workflow)).not.toThrow(); + return expect(executeWorkflow(workflow, { + resolveCommand: () => ({ site: 'a', name: 'extract', browser: false, access: 'read', description: '', args: [] }), + execute: async () => ({}), + })).rejects.toThrow(/did not produce context\.event\.name/); + }); + + it('rejects unknown and future dependencies', () => { + const unknown = baseWorkflow({ dependsOn: ['does-not-exist'] }); + expect(() => validateWorkflow(unknown)).toThrow(/unknown step/); + + const future: Workflow = { + goal: 'invalid', + steps: [ + { id: 'first', site: 'a', action: 'command', command: 'a/first', dependsOn: ['second'] }, + { id: 'second', site: 'a', action: 'command', command: 'a/second' }, + ], + }; + expect(() => validateWorkflow(future)).toThrow(/not earlier/); + }); + + it('rejects consuming context before the producing step', () => { + const workflow: Workflow = { + goal: 'invalid', + steps: [{ id: 'consumer', site: 'b', action: 'fill', command: 'b/fill', args: { value: '${{ context.event.name }}' } }], + }; + expect(() => validateWorkflow(workflow)).toThrow(/consumes context\.event\.name before it is produced/); + }); + + it('runs browser scripts with context produced by an earlier script', async () => { + const calls: Array<{ script: string; step: string }> = []; + const workflow: Workflow = { + goal: 'cross-site browser task', + steps: [ + { id: 'source', site: 'site-a', action: 'extract', command: 'browser/run', script: 'return { name: "OB Fitness Challenge" };', outputs: { 'event.name': 'name' } }, + { id: 'target', site: 'site-b', action: 'fill', command: 'browser/run', dependsOn: ['source'], script: 'return { filled: workflowContext.event.name };' }, + ], + }; + const result = await executeWorkflow(workflow, { + executeScript: async (script, step) => { + calls.push({ script, step: step.id }); + return step.id === 'source' ? { name: 'OB Fitness Challenge' } : { filled: 'OB Fitness Challenge' }; + }, + }); + expect(result.context.event).toEqual({ name: 'OB Fitness Challenge' }); + expect(calls[1]?.script).toContain('OB Fitness Challenge'); + }); + + it('runs an evaluate step returned by Gemini without requiring an adapter', async () => { + const workflow: Workflow = { + goal: 'browser evaluate', + steps: [{ + id: 'evaluate', + site: 'site-a', + action: 'extract', + command: 'evaluate', + args: { code: 'return { title: "OB Fitness Challenge" };' }, + outputs: { 'event.name': 'title' }, + }], + }; + const result = await executeWorkflow(workflow, { + executeScript: async script => { + expect(script).toContain('return { title: "OB Fitness Challenge" }'); + return { title: 'OB Fitness Challenge' }; + }, + }); + expect(result.context.event).toEqual({ name: 'OB Fitness Challenge' }); + }); + + it('translates a Gemini goto step into browser navigation', async () => { + const workflow: Workflow = { + goal: 'navigate', + steps: [{ id: 'navigate', site: 'site-a', action: 'navigate', command: 'goto', args: { url: 'https://example.com' } }], + }; + await expect(executeWorkflow(workflow, { + executeScript: async script => { + expect(script).toContain('page.goto("https://example.com")'); + return { url: 'https://example.com' }; + }, + })).resolves.toBeDefined(); + }); + + it('derives a basic site URL when Gemini omits goto arguments', async () => { + await expect(executeWorkflow({ + goal: 'open amazon', + steps: [{ id: 'open', site: 'amazon', action: 'navigate', command: 'goto' }], + }, { + executeScript: async script => { + expect(script).toContain('https://amazon.com'); + return { opened: true }; + }, + })).resolves.toBeDefined(); + }); + + it('accepts only validated structured JSON from Gemini', async () => { + const client = { + models: { + generateContent: async () => ({ + text: JSON.stringify({ + goal: 'find an event', + steps: [{ id: 'find', site: 'site-a', action: 'search', command: 'browser/run', script: 'return { ok: true };' }], + }), + }), + }, + } as never; + const workflow = await planWithGemini('Find an event on Site A.', { client }); + expect(workflow.steps[0]?.command).toBe('browser/run'); + }); +}); + +function baseWorkflow(overrides: Partial): Workflow { + return { + goal: 'test', + steps: [ + { id: 'source', site: 'a', action: 'extract', command: 'a/extract', ...overrides }, + ], + }; +} \ No newline at end of file diff --git a/src/planner/index.ts b/src/planner/index.ts new file mode 100644 index 000000000..1eec8b897 --- /dev/null +++ b/src/planner/index.ts @@ -0,0 +1,287 @@ +import { ConfigError } from '../errors.js'; +import { executeCommand } from '../execution.js'; +import { getRegistry, type CliCommand, type CommandArgs } from '../registry.js'; +import { sendCommand } from '../browser/daemon-client.js'; +import { profileRouteParams, resolveProfileSelection } from '../browser/profile.js'; + +export type WorkflowAction = 'navigate' | 'search' | 'extract' | 'fill' | 'click' | 'submit' | 'command'; + +export interface WorkflowStep { + id: string; + site: string; + action: WorkflowAction; + command: string; + args?: Record; + dependsOn?: string[]; + /** Map context paths to dot paths in the command result. */ + outputs?: Record; + /** Optional sandboxed Playwright program for adapter-free browser work. */ + script?: string; +} + +export interface Workflow { + goal: string; + steps: WorkflowStep[]; +} + +export type WorkflowContext = Record; + +export interface WorkflowExecutionOptions { + context?: WorkflowContext; + profile?: string; + session?: string; + debug?: boolean; + resolveCommand?: (step: WorkflowStep) => CliCommand | undefined; + execute?: (command: CliCommand, args: CommandArgs, debug: boolean, step: WorkflowStep) => Promise; + executeScript?: (script: string, step: WorkflowStep, context: WorkflowContext) => Promise; +} + +export interface NaturalLanguagePlannerOptions { + commands?: { + source?: Partial>; + target?: Partial>; + }; +} + +export interface WorkflowExecutionResult { + context: WorkflowContext; + results: Record; +} + +const ACTIONS = new Set(['navigate', 'search', 'extract', 'fill', 'click', 'submit', 'command']); +const TEMPLATE = /^\$\{\{\s*context\.([^}]+?)\s*\}\}$/; + +/** Validate the workflow IR before any browser or adapter work starts. */ +export function validateWorkflow(workflow: Workflow): void { + if (!workflow || typeof workflow !== 'object' || !Array.isArray(workflow.steps) || workflow.steps.length === 0) { + throw new ConfigError('Workflow must contain at least one step.', 'Provide an ordered workflow with executable steps.'); + } + + const ids = new Set(); + const produced = new Set(); + for (let index = 0; index < workflow.steps.length; index++) { + const step = workflow.steps[index]; + if (!step?.id || ids.has(step.id)) throw invalidWorkflow(`Step IDs must be unique: "${step?.id ?? ''}".`); + if (!step.site || !step.command || !ACTIONS.has(step.action)) throw invalidWorkflow(`Step "${step.id}" is missing a valid site, action, or command.`); + ids.add(step.id); + + for (const dependency of step.dependsOn ?? []) { + const dependencyIndex = workflow.steps.findIndex(candidate => candidate.id === dependency); + if (dependencyIndex === -1) throw invalidWorkflow(`Step "${step.id}" depends on unknown step "${dependency}".`); + if (dependencyIndex >= index) throw invalidWorkflow(`Step "${step.id}" depends on "${dependency}", which is not earlier in the workflow.`); + } + for (const reference of findContextReferences(step.args)) { + if (!produced.has(reference)) throw invalidWorkflow(`Step "${step.id}" consumes context.${reference} before it is produced.`); + } + for (const key of Object.keys(step.outputs ?? {})) { + if (!key || produced.has(key)) throw invalidWorkflow(`Workflow output "${key}" is produced more than once.`); + produced.add(key); + } + } +} + +/** Execute steps through Webcmd's existing command executor. */ +export async function executeWorkflow(workflow: Workflow, options: WorkflowExecutionOptions = {}): Promise { + validateWorkflow(workflow); + const context: WorkflowContext = options.context ?? {}; + const results: Record = {}; + const resolveCommand = options.resolveCommand ?? ((step: WorkflowStep) => getRegistry().get(step.command) ?? getRegistry().get(`${step.site}/${step.command}`)); + const runCommand = options.execute ?? ((command, args, debug) => executeCommand(command, args, debug, { + profile: options.profile, + session: options.session, + })); + + for (const step of workflow.steps) { + const browserScript = getBrowserScript(step, context); + if (browserScript !== undefined) { + const result = await (options.executeScript ?? executeBrowserScript)(browserScript, step, context); + results[step.id] = result; + for (const [key, path] of Object.entries(step.outputs ?? {})) { + const value = readPath(result, path); + if (value === undefined || value === null) throw new ConfigError(`Step "${step.id}" did not produce context.${key} at result path "${path}".`, 'Check the browser script return value or workflow output mapping.'); + writePath(context, key, value); + } + continue; + } + const command = resolveCommand(step); + if (!command) throw new ConfigError(`Workflow step "${step.id}" references unavailable command "${step.command}".`, 'Load the adapter or provide a resolveCommand function.'); + const args = resolveValue(step.args ?? {}, context) as CommandArgs; + const result = await runCommand(command, args, options.debug ?? false, step); + results[step.id] = result; + for (const [key, path] of Object.entries(step.outputs ?? {})) { + const value = readPath(result, path); + if (value === undefined || value === null) throw new ConfigError(`Step "${step.id}" did not produce context.${key} at result path "${path}".`, 'Check the extraction command output or workflow output mapping.'); + writePath(context, key, value); + } + } + return { context, results }; +} + +/** A deterministic baseline planner for the common find-then-submit shape. */ +export function planNaturalLanguageGoal(goal: string, options: NaturalLanguagePlannerOptions = {}): Workflow { + const match = goal.match(/^(?:find|locate|search for)\s+(.+?)\s+on\s+(.+?)\s+and\s+(?:submit|enter|use)\s+(?:its|the)?\s*(?:details|information|data)?\s*on\s+(.+?)\.?$/i); + if (!match) throw new ConfigError('Could not decompose the goal into a source search and target submission.', 'Use a structured planner or provide a goal like: Find on and submit its details on .'); + const [, query, sourceLabel, targetLabel] = match; + const source = slug(sourceLabel); + const target = slug(targetLabel); + const sourceCommands = options.commands?.source ?? {}; + const targetCommands = options.commands?.target ?? {}; + const command = (site: 'source' | 'target', action: WorkflowAction): string => { + const configured = site === 'source' ? sourceCommands[action] : targetCommands[action]; + return configured ?? `${site === 'source' ? source : target}/${action}`; + }; + const fields = ['name', 'date', 'location']; + return { + goal, + steps: [ + { id: 'source-search', site: source, action: 'search', command: command('source', 'search'), args: { query }, outputs: { 'search.result': 'result' } }, + { id: 'source-extract', site: source, action: 'extract', command: command('source', 'extract'), args: { result: '${{ context.search.result }}', fields }, dependsOn: ['source-search'], outputs: Object.fromEntries(fields.map(field => [`event.${field}`, field])) }, + { id: 'target-navigate', site: target, action: 'navigate', command: command('target', 'navigate') }, + ...fields.map(field => ({ id: `target-fill-${field}`, site: target, action: 'fill' as const, command: command('target', 'fill'), args: { field, value: `\${{ context.event.${field} }}` }, dependsOn: ['source-extract'] })), + { id: 'target-submit', site: target, action: 'submit', command: command('target', 'submit'), dependsOn: fields.map(field => `target-fill-${field}`) }, + ], + }; +} + +export const planGoal = planNaturalLanguageGoal; +export const runWorkflow = executeWorkflow; + +/** Execute a planned workflow in an existing Webcmd browser Session. */ +export async function runBrowserWorkflow(workflow: Workflow, options: WorkflowExecutionOptions & { session: string }): Promise { + const context = options.context ?? {}; + return executeWorkflow(workflow, { + ...options, + context, + executeScript: options.executeScript ?? ((script) => sendCommand('run', { + session: options.session, + surface: 'browser', + ...profileRouteParams(resolveProfileSelection(options.profile)), + source: script, + timeoutMs: 120_000, + timeout: 125, + snapshotMode: 'act', + })), + }); +} + +function invalidWorkflow(message: string): ConfigError { + return new ConfigError(`Invalid workflow: ${message}`, 'Fix the workflow dependencies and output references before execution.'); +} + +function slug(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +} + +function findContextReferences(value: unknown): string[] { + const references: string[] = []; + if (typeof value === 'string') { + const match = value.match(TEMPLATE); + if (match) references.push(match[1]); + } else if (Array.isArray(value)) { + for (const item of value) references.push(...findContextReferences(item)); + } else if (value && typeof value === 'object') { + for (const item of Object.values(value)) references.push(...findContextReferences(item)); + } + return references; +} + +function resolveValue(value: unknown, context: WorkflowContext): unknown { + if (typeof value === 'string') { + const match = value.match(TEMPLATE); + if (!match) return value; + const resolved = readPath(context, match[1]); + if (resolved === undefined || resolved === null) throw new ConfigError(`Missing workflow context value: context.${match[1]}`, 'Ensure the producing step ran successfully and mapped its output.'); + return resolved; + } + if (Array.isArray(value)) return value.map(item => resolveValue(item, context)); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveValue(item, context)])); + return value; +} + +function readPath(value: unknown, path: string): unknown { + const parts = path.replace(/^result\./, '').split('.').filter(Boolean); + let current = value; + for (const part of parts) { + if (current && typeof current === 'object' && part in current) current = (current as Record)[part]; + else return undefined; + } + return current; +} + +function writePath(target: WorkflowContext, path: string, value: unknown): void { + const parts = path.split('.').filter(Boolean); + let current: Record = target; + for (const part of parts.slice(0, -1)) { + const existing = current[part]; + if (!existing || typeof existing !== 'object' || Array.isArray(existing)) current[part] = {}; + current = current[part] as Record; + } + current[parts[parts.length - 1]!] = value; +} + +function resolveScript(script: string, context: WorkflowContext): string { + return `const workflowContext = ${JSON.stringify(context)};\n${script}`; +} + +async function executeBrowserScript(_script: string, _step: WorkflowStep, _context: WorkflowContext): Promise { + throw new ConfigError('Browser scripts require an explicit Session.', 'Use runBrowserWorkflow with a Webcmd Session ID.'); +} + +function getBrowserScript(step: WorkflowStep, context: WorkflowContext): string | undefined { + if (step.script) return resolveScript(step.script, context); + const args = browserArgs(step, context); + if (['evaluate', 'browser/run', 'run'].includes(step.command)) { + const source = args.script ?? args.code ?? args.js; + return typeof source === 'string' ? resolveScript(source, context) : undefined; + } + if (step.command === 'goto' || step.command === 'navigate') { + const url = args.url ?? args.href ?? args.uri ?? args.destination ?? args.target ?? args.value ?? siteUrl(step.site); + return typeof url === 'string' + ? `await page.goto(${JSON.stringify(url)}); return { url: page.url(), title: await page.title() };` + : undefined; + } + if (step.command === 'click') { + const target = args.selector ?? args.ref ?? args.text; + return typeof target === 'string' + ? `await page.locator(${JSON.stringify(target)}).click(); return { clicked: true };` + : undefined; + } + if (step.command === 'fill' || step.command === 'type') { + const target = args.selector ?? args.ref; + const value = args.value ?? args.text; + return typeof target === 'string' && typeof value === 'string' + ? `await page.locator(${JSON.stringify(target)}).fill(${JSON.stringify(value)}); return { filled: true };` + : undefined; + } + if (step.command === 'press') { + const key = args.key ?? args.text; + return typeof key === 'string' + ? `await page.keyboard.press(${JSON.stringify(key)}); return { pressed: true };` + : undefined; + } + if (step.command === 'submit') { + const target = args.selector ?? args.ref; + return typeof target === 'string' + ? `await page.locator(${JSON.stringify(target)}).click(); return { submitted: true };` + : `await page.locator('button[type="submit"], input[type="submit"]').first().click(); return { submitted: true };`; + } + return undefined; +} + +function browserArgs(step: WorkflowStep, context: WorkflowContext): Record { + const resolved = resolveValue(step.args ?? {}, context); + if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) return {}; + const record = resolved as Record; + for (const key of ['params', 'options', 'input']) { + const nested = record[key]; + if (nested && typeof nested === 'object' && !Array.isArray(nested)) return { ...record, ...(nested as Record) }; + } + return record; +} + +function siteUrl(site: string): string | undefined { + const normalized = site.trim().toLowerCase(); + if (!normalized || !/^[a-z0-9.-]+$/.test(normalized)) return undefined; + if (normalized.includes('.')) return `https://${normalized}`; + return `https://${normalized}.com`; +} \ No newline at end of file diff --git a/src/workflow-ui-server.ts b/src/workflow-ui-server.ts new file mode 100644 index 000000000..0e293c579 --- /dev/null +++ b/src/workflow-ui-server.ts @@ -0,0 +1,89 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { planWithGemini } from './planner/gemini.js'; +import { runBrowserWorkflow } from './planner/index.js'; +import { sendCommand } from './browser/daemon-client.js'; +import { profileRouteParams, resolveProfileSelection } from './browser/profile.js'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const uiDir = path.resolve(root, '../ui'); +const requestedPort = Number(process.env.WEBCMD_UI_PORT ?? 4317); + +type RequestBody = { goal?: string; profile?: string; session?: string; model?: string }; + +function sendJson(response: ServerResponse, status: number, value: unknown): void { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + response.end(JSON.stringify(value)); +} + +async function body(request: IncomingMessage): Promise { + let raw = ''; + for await (const chunk of request) raw += String(chunk); + try { return JSON.parse(raw) as RequestBody; } catch { return {}; } +} + +async function workflow(request: IncomingMessage, execute: boolean): Promise { + const input = await body(request); + if (!input.goal?.trim()) throw new Error('A natural-language goal is required.'); + const workflow = await planWithGemini(input.goal, { model: input.model }); + if (!execute) return { workflow }; + + const profile = resolveProfileSelection(input.profile || 'default'); + const profileId = profile?.contextId ?? 'default'; + let session = input.session?.trim(); + let created = false; + if (!session) { + const createdData = await sendCommand('session-create', { contextId: profileId, sessionName: 'ui-workflow' }); + if (!createdData || typeof createdData !== 'object' || typeof (createdData as { id?: unknown }).id !== 'string') throw new Error('Browser daemon did not return a Session ID.'); + session = (createdData as { id: string }).id; + created = true; + } + try { + return { session, ...(await runBrowserWorkflow(workflow, { session, profile: input.profile, context: {} })) }; + } finally { + if (created) { + await sendCommand('session-close', { contextId: profileId, session, force: true, discard: true }).catch(() => undefined); + } + } +} + +async function serveStatic(request: IncomingMessage, response: ServerResponse): Promise { + const pathname = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`).pathname; + const file = pathname === '/' ? 'index.html' : pathname.slice(1); + if (!['index.html', 'styles.css', 'app.js'].includes(file)) { response.writeHead(404); response.end('Not found'); return; } + const content = await readFile(path.join(uiDir, file)); + const type = file.endsWith('.html') ? 'text/html' : file.endsWith('.css') ? 'text/css' : 'text/javascript'; + response.writeHead(200, { 'content-type': `${type}; charset=utf-8` }); + response.end(content); +} + +const server = createServer(async (request, response) => { + try { + if (request.method === 'POST' && request.url === '/api/plan') sendJson(response, 200, await workflow(request, false)); + else if (request.method === 'POST' && request.url === '/api/run') sendJson(response, 200, await workflow(request, true)); + else if (request.method === 'GET') await serveStatic(request, response); + else sendJson(response, 405, { error: 'Method not allowed' }); + } catch (error) { + sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) }); + } +}); + +function listen(port: number): void { + const onError = (error: NodeJS.ErrnoException): void => { + server.off('error', onError); + if (error.code === 'EADDRINUSE' && port < requestedPort + 20) { + listen(port + 1); + return; + } + throw error; + }; + server.once('error', onError); + server.listen(port, '127.0.0.1', () => { + server.off('error', onError); + console.log(`Webcmd workflow UI: http://127.0.0.1:${port}`); + }); +} + +listen(requestedPort); diff --git a/ui/app.js b/ui/app.js new file mode 100644 index 000000000..927fa663e --- /dev/null +++ b/ui/app.js @@ -0,0 +1,41 @@ +const $ = (id) => document.getElementById(id); +const goal = $('goal'); +const timeline = $('timeline'); +const state = $('activityState'); +const resultPanel = $('resultPanel'); +const result = $('result'); +let running = false; + +function log(message, detail = '') { + if (timeline.querySelector('.empty-state')) timeline.innerHTML = ''; + const row = document.createElement('div'); + row.className = 'event'; + row.innerHTML = `
${escapeHtml(message)}${escapeHtml(detail || new Date().toLocaleTimeString())}
`; + timeline.appendChild(row); + timeline.scrollTop = timeline.scrollHeight; +} +function escapeHtml(value) { return String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } +function options() { return { goal: goal.value.trim(), profile: $('profile').value.trim() || 'default', session: $('session').value.trim(), model: $('model').value.trim() }; } +async function request(path, payload) { + const response = await fetch(path, { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify(payload) }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Workflow request failed'); + return data; +} +async function preview() { + if (!goal.value.trim() || running) return; + running = true; state.textContent = 'Planning'; state.className = 'pill running'; log('Asking Gemini for a workflow plan'); + try { const data = await request('/api/plan', options()); resultPanel.classList.remove('hidden'); result.textContent = JSON.stringify(data.workflow, null, 2); log(`${data.workflow.steps.length} steps validated`, 'Plan ready'); state.textContent = 'Plan ready'; state.className = 'pill done'; } + catch (error) { log(error.message, 'Error'); state.textContent = 'Error'; } + finally { running = false; } +} +async function run() { + if (!goal.value.trim() || running) return; + running = true; state.textContent = 'Running'; state.className = 'pill running'; log('Planning workflow', 'Gemini'); + try { const data = await request('/api/run', options()); resultPanel.classList.remove('hidden'); result.textContent = JSON.stringify(data, null, 2); log('Workflow completed', 'Browser result received'); state.textContent = 'Complete'; state.className = 'pill done'; } + catch (error) { log(error.message, 'Workflow stopped'); state.textContent = 'Error'; state.className = 'pill'; resultPanel.classList.remove('hidden'); result.textContent = error.stack || error.message; } + finally { running = false; } +} +$('preview').addEventListener('click', preview); $('run').addEventListener('click', run); $('clear').addEventListener('click', () => { resultPanel.classList.add('hidden'); result.textContent = ''; }); +document.querySelectorAll('[data-goal]').forEach(button => button.addEventListener('click', () => { goal.value = button.dataset.goal; goal.focus(); })); +document.addEventListener('keydown', event => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'p') { event.preventDefault(); preview(); } }); diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 000000000..d0db90cc6 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,52 @@ + + + + + + Webcmd Workflow Console + + + +
+
+
Wwebcmd/workflow console
+
Local runner ready
+
+ +
+

Natural language browser automation

+

Tell the browser what to do.

+

Describe a task across websites. Gemini turns it into a checked workflow, then Webcmd runs it in a real browser session.

+
+ +
+
+
01

Your goal

Read-only until submission
+ + +
Try:
+ +
+ + + +
+
+

External writes should be reviewed before you run them. The browser may pause for sign-in or confirmation.

+
+ + +
+ + +
Webcmd local consoleGEMINI_API_KEY stays in your terminal environment
+
+ + + diff --git a/ui/styles.css b/ui/styles.css new file mode 100644 index 000000000..17b307c54 --- /dev/null +++ b/ui/styles.css @@ -0,0 +1,3 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap'); +:root{--ink:#17252b;--muted:#728087;--line:#d8e1de;--paper:#f6f8f3;--panel:#fff;--mint:#bce9cf;--green:#176447;--coral:#e97455;--yellow:#f8dda0;--shadow:0 18px 45px rgba(23,37,43,.08)} +*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:Manrope, sans-serif}body:before{content:"";position:fixed;inset:0;pointer-events:none;background:linear-gradient(135deg,rgba(188,233,207,.23),transparent 35%),radial-gradient(circle at 92% 8%,rgba(248,221,160,.28),transparent 22%)}.shell{width:min(1180px,calc(100% - 44px));margin:auto;position:relative}.topbar{height:86px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);font-size:13px}.brand{display:flex;align-items:center;gap:9px;font-weight:800;letter-spacing:-.03em}.brand-mark{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:var(--ink);color:var(--mint);font-family:'DM Mono';font-size:14px}.slash,.muted{color:var(--muted);font-weight:500}.status{display:flex;align-items:center;gap:8px;color:var(--green);font-size:12px}.status-dot{width:7px;height:7px;border-radius:50%;background:#35ad72;box-shadow:0 0 0 4px rgba(53,173,114,.13)}.intro{padding:76px 0 46px;max-width:770px}.eyebrow{font:500 12px 'DM Mono';text-transform:uppercase;letter-spacing:.1em;color:var(--coral);margin:0 0 18px}.intro h1{font-size:clamp(38px,6vw,70px);line-height:1.02;letter-spacing:-.06em;margin:0 0 20px}.lede{font-size:17px;line-height:1.7;color:#637178;max-width:650px;margin:0}.workspace{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr);gap:18px}.panel{background:rgba(255,255,255,.82);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.composer,.activity{padding:28px}.panel-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:22px}.panel-heading>div{display:flex;align-items:center;gap:12px}.section-number{font:500 11px 'DM Mono';color:var(--coral)}h2{font-size:17px;letter-spacing:-.03em;margin:0}.hint{font-size:11px;color:var(--muted);font-family:'DM Mono'}.pill{font:500 10px 'DM Mono';padding:7px 10px;border-radius:99px;background:#eef2ef;color:var(--muted);text-transform:uppercase}.pill.running{background:var(--yellow);color:#735d21}.pill.done{background:var(--mint);color:var(--green)}textarea{width:100%;resize:vertical;min-height:142px;border:1px solid var(--line);border-radius:10px;padding:17px 18px;font:15px/1.6 Manrope;color:var(--ink);background:#fbfcf9;outline:none;transition:border .2s,box-shadow .2s}textarea:focus{border-color:#69ae8b;box-shadow:0 0 0 4px rgba(105,174,139,.12)}.examples{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:13px 0 27px;color:var(--muted);font-size:11px}.examples button{border:0;background:transparent;color:#547169;padding:0;text-decoration:underline;text-underline-offset:3px;cursor:pointer;font:11px Manrope}.controls{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;border-top:1px solid var(--line);padding-top:20px}.controls label{font:500 10px 'DM Mono';color:var(--muted);text-transform:uppercase}.controls input{display:block;width:100%;margin-top:7px;border:1px solid var(--line);border-radius:7px;background:#fff;padding:10px 11px;color:var(--ink);font:12px 'DM Mono';outline:none}.actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}.actions button{border:0;border-radius:8px;padding:12px 16px;font:700 12px Manrope;cursor:pointer}.primary{background:var(--ink);color:white}.primary:hover{background:#28545a}.secondary{background:#edf3ef;color:var(--green);display:flex;gap:14px;align-items:center}.secondary kbd{font:10px 'DM Mono';color:var(--muted)}.play{font-size:10px;color:var(--mint)}.safety-note{font-size:11px;color:var(--muted);margin:19px 0 0}.safety-note span{color:var(--coral);font-size:14px;margin-right:5px}.timeline{min-height:330px;border-top:1px solid var(--line);padding-top:20px}.empty-state{height:290px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:var(--muted);font-size:12px;text-align:center}.empty-state strong{color:var(--ink);font-size:13px}.empty-icon{display:grid;place-items:center;width:43px;height:43px;border-radius:50%;background:var(--mint);color:var(--green);font-size:21px;margin-bottom:5px}.event{display:flex;gap:11px;padding:9px 0;font-size:12px}.event-dot{width:7px;height:7px;border-radius:50%;background:var(--coral);margin-top:5px;flex:0 0 auto}.event small{display:block;color:var(--muted);font:10px 'DM Mono';margin-top:3px}.result-panel{margin-top:18px;padding:28px}.result-panel pre{background:#17252b;color:#d8f3de;border-radius:9px;padding:18px;white-space:pre-wrap;overflow:auto;font:12px/1.6 'DM Mono';max-height:400px}.hidden{display:none}.icon-button{border:0;background:transparent;color:var(--muted);font-size:23px;cursor:pointer}footer{display:flex;justify-content:space-between;padding:28px 2px 40px;color:var(--muted);font:10px 'DM Mono'}footer span:last-child{color:#9aaba7}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:800px){.shell{width:min(100% - 28px,620px)}.topbar{height:70px}.topbar .muted,.slash{display:none}.intro{padding:55px 0 32px}.workspace{grid-template-columns:1fr}.controls{grid-template-columns:1fr}.activity{min-height:0}.timeline{min-height:190px}.empty-state{height:160px}footer{flex-direction:column;gap:8px}.actions{justify-content:stretch}.actions button{flex:1}}