diff --git a/CHANGELOG.md b/CHANGELOG.md index b073bb2..4ebdb99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.3.0 + +- Web feedback widget for the people using your web app: `agentback/web` (`createFeedbackWidget`) and a script-tag build at `agentback/widget.js`, served at https://agentback.app/widget.js. +- New `web` source and `page` field on reports. + ## 0.1.1 - Fix release package checks for npm 12's JSON output. diff --git a/README.md b/README.md index ab114c9..c071128 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # agentback -Collect feedback from AI agents inside the MCP server, CLI, or API you already ship, and manage it in the [Agentback](https://agentback.app) inbox. +Collect feedback from AI agents and the people using your product, inside the MCP server, CLI, API, or web app you already ship, and manage it in the [Agentback](https://agentback.app) inbox. ```sh npm install agentback ``` -Requires Node.js 20 or newer; the core client also runs on Cloudflare Workers, -Deno, and Bun. The MCP integration also needs the MCP TypeScript SDK (either +Requires Node.js 20 or newer; the core client also runs in browsers, Cloudflare +Workers, Deno, and Bun. The MCP integration also needs the MCP TypeScript SDK (either `@modelcontextprotocol/sdk` 1.23+ or `@modelcontextprotocol/server` 2.x) and `zod` 4.2+; the core client and CLI helpers can be used without those optional peers. @@ -60,6 +60,54 @@ program `mycli feedback [--category bug|feature_request|docs|other] [--intent text] [--request-id id] [--model name] [--json] `; use `-` to read the message from stdin. +## Web app + +Let the people using your web app send feedback from a small panel. One script tag, using the public key: + +```html + +``` + +Or from npm, in apps with a bundler: + +```ts +import { createFeedbackWidget } from 'agentback/web'; + +const feedback = createFeedbackWidget({ + projectKey: 'af_...', + product: 'My product', +}); +``` + +A floating Feedback button opens the panel. Reports carry source `web` and the page's address (origin and path, never the query string). The panel follows the visitor's light or dark setting and uses your page's font. + +| Option (`data-` attribute) | Default | | +| -------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `color` | `orange` | `orange`, `neutral`, `blue`, `violet`, or any hex colour | +| `launcher` | `true` | `false` hides the floating button; open the panel from your own menu | +| `position` | `bottom-right` | or `bottom-left` | +| `theme` | `auto` | `light` or `dark` to match your app's own switch | +| `categories` | `bug,feature_request,other` | order, relabel (`bug:Report a bug,other`) or hide (`false`) the types; stored values stay `bug`, `feature_request`, `docs`, `other` | +| `product` | | your product's name, used in the thank-you message | +| `locale` | page `lang` | `en` or `zh`; override any string with the `text` option | +| `page` | `true` | `false` leaves out the page address | + +To open it from your own button, mark the element; no code needed: + +```html + +``` + +The widget is also available as `window.Agentback` after the `agentback:ready` event (the return value of `createFeedbackWidget` from npm): `open(anchor?)`, `close()`, `toggle()`, `identify(identity | null)`, `setTheme(theme)` and `destroy()`. + +To attach the signed-in user, sign their account on your backend as in [Verified accounts](#verified-accounts), then pass both to the page: `data-account` and `data-signature` on the script tag, or `Agentback.identify({ account, signature })` after sign-in. Without a signature, reports stay anonymous. + +To build your own form instead, call `send()` from the browser with `{ source: 'web' }` and an optional `page`. + ## Verified accounts To know which of your users sent a report, sign their account reference on your backend and pass it to the integration: diff --git a/package-lock.json b/package-lock.json index bd909c0..bb071d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,39 +1,487 @@ { "name": "agentback", - "version": "0.1.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentback", - "version": "0.1.1", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@modelcontextprotocol/client": "2.4.0", "@modelcontextprotocol/sdk": "1.32.1", "@modelcontextprotocol/server": "2.4.0", "@types/node": "22.19.0", + "esbuild": "^0.28.2", + "happy-dom": "^21.0.3", "prettier": "3.9.9", "typescript": "5.9.3", "vitest": "5.0.3", "zod": "4.6.5" }, "engines": { - "node": ">=22.14" + "node": ">=20" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.20.0", - "zod": "^3.25 || ^4.0" + "@modelcontextprotocol/sdk": "^1.23.0", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.2.0" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { "optional": true }, + "@modelcontextprotocol/server": { + "optional": true + }, "zod": { "optional": true } } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hono/node-server": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", @@ -289,9 +737,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -310,9 +755,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -331,9 +773,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -352,9 +791,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -373,9 +809,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -394,9 +827,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -504,6 +934,23 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/mocker": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", @@ -640,6 +1087,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -839,6 +1299,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -879,6 +1352,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1179,6 +1694,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/happy-dom": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.3.tgz", + "integrity": "sha512-PesNgu5BnIEmAwzKGewB3K1ORFeznFZJ4JrRz89ea1wkHnxskdpzX9sN/+zb4KO6k8oSMhyJSj/cwXAFrYBBNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1467,9 +2001,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1492,9 +2023,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1517,9 +2045,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1542,9 +2067,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2501,6 +3023,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2537,6 +3069,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index 384f83b..b048a7b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "agentback", - "version": "0.2.0", - "description": "Collect feedback from AI agents inside your MCP server, CLI, or API and manage it in Agentback.", + "version": "0.3.0", + "description": "Collect feedback from AI agents and the people using your product: inside your MCP server, CLI, API, or web app, managed in Agentback.", "license": "MIT", "homepage": "https://agentback.app", "repository": { @@ -25,10 +25,15 @@ "./cli": { "types": "./dist/cli.d.ts", "default": "./dist/cli.js" - } + }, + "./web": { + "types": "./dist/web.d.ts", + "default": "./dist/web.js" + }, + "./widget.js": "./dist/widget.js" }, "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && esbuild src/widget.ts --bundle --minify --format=iife --target=es2020 --legal-comments=none --outfile=dist/widget.js", "typecheck": "tsc --noEmit", "test": "vitest run", "format:check": "prettier --check .", @@ -56,6 +61,8 @@ "@modelcontextprotocol/sdk": "1.32.1", "@modelcontextprotocol/server": "2.4.0", "@types/node": "22.19.0", + "esbuild": "^0.28.2", + "happy-dom": "^21.0.3", "prettier": "3.9.9", "typescript": "5.9.3", "vitest": "5.0.3", diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 9ad08fc..ac5e500 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -16,10 +16,11 @@ try { // npm 12 keys results by package name; earlier npm versions use an array. const [pack] = Array.isArray(packed) ? packed : Object.values(packed); const files = pack.files.map(({ path }) => path); - for (const entry of ['index', 'mcp', 'cli']) { + for (const entry of ['index', 'mcp', 'cli', 'web']) { assert(files.includes(`dist/${entry}.js`)); assert(files.includes(`dist/${entry}.d.ts`)); } + assert(files.includes('dist/widget.js')); assert(files.includes('LICENSE')); assert(files.includes('README.md')); assert( @@ -49,6 +50,9 @@ try { import assert from 'node:assert/strict'; import { Agentback, signAccount } from 'agentback'; import { feedbackHint } from 'agentback/cli'; + import { createFeedbackWidget } from 'agentback/web'; + assert.equal(typeof createFeedbackWidget, 'function'); + assert(import.meta.resolve('agentback/widget.js').endsWith('/dist/widget.js')); assert.equal(typeof signAccount, 'function'); assert.match(feedbackHint('demo'), /demo feedback/); const client = new Agentback({ diff --git a/src/index.ts b/src/index.ts index 803d352..415378e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export const DEFAULT_ORIGIN = 'https://agentback.app'; export const categories = ['bug', 'feature_request', 'docs', 'other'] as const; export type Category = (typeof categories)[number]; -export type Source = 'direct' | 'api' | 'mcp' | 'cli'; +export type Source = 'direct' | 'api' | 'mcp' | 'cli' | 'web'; export interface Feedback { /** What went wrong or what was missing. Required, at most 4000 characters. */ @@ -13,10 +13,15 @@ export interface Feedback { /** The id of the original call that had the problem, not of this report. */ request_id?: string; agent?: { name?: string; model?: string }; + /** The web page the report was sent from, e.g. `https://example.com/billing`. */ + page?: string; } export interface SendOptions { - /** Defaults to `mcp`/`cli` from the integrations, otherwise `direct` or `api`. */ + /** + * Defaults to `mcp`/`cli`/`web` from the integrations, otherwise `direct` + * or `api`. Use `web` for reports a person typed in your web app. + */ source?: Source; /** * Your opaque reference for the end user or tenant. Never an email. diff --git a/src/web.ts b/src/web.ts new file mode 100644 index 0000000..731e798 --- /dev/null +++ b/src/web.ts @@ -0,0 +1,562 @@ +import { + Agentback, + AgentbackError, + categories as allCategories, + identityOptions, + type Category, + type Identity, + type Receipt, +} from './index.js'; + +export type WidgetColor = + 'orange' | 'neutral' | 'blue' | 'violet' | (string & {}); +export type WidgetTheme = 'auto' | 'light' | 'dark'; +export type WidgetLocale = 'en' | 'zh'; +export type WidgetCategory = Category | { value: Category; label: string }; + +export interface WidgetText { + launcher: string; + title: string; + categories: string; + field: string; + placeholder: string; + pageNote: string; + send: string; + sending: string; + retry: string; + close: string; + sentTitle: string; + /** `{product}` is replaced with the `product` option when it is set. */ + sentBody: string; + another: string; + failed: string; + rateLimited: string; + poweredBy: string; + bug: string; + feature_request: string; + docs: string; + other: string; +} + +export interface FeedbackWidgetOptions { + /** The project's public key (`af_...`). Write-only, safe in a web page. */ + projectKey: string; + origin?: string; + /** Your product's name, used in the thank-you message. */ + product?: string; + /** A preset or any CSS hex colour. Defaults to `orange`. */ + color?: WidgetColor; + /** `auto` follows the visitor's system setting. */ + theme?: WidgetTheme; + /** Show the floating button. Turn it off to open from your own menu. */ + launcher?: boolean; + position?: 'bottom-right' | 'bottom-left'; + /** + * Which types people can pick, in order, optionally relabelled. + * `false` hides the picker. Reports without a type are stored as `other`. + */ + categories?: false | WidgetCategory[]; + /** Defaults to the page's `lang`: `zh` for Chinese, otherwise `en`. */ + locale?: WidgetLocale; + text?: Partial; + /** The signed-in user, signed on your backend with `signAccount`. */ + identity?: Identity; + /** Send the page's address (origin and path, never the query). */ + includePage?: boolean; + /** Clicks on `[data-agentback-open]` elements open the widget. */ + bindTriggers?: boolean; + container?: HTMLElement; + fetch?: typeof fetch; + onSent?: (receipt: Receipt) => void; +} + +export interface FeedbackWidget { + /** Opens the panel, next to `anchor` when given (e.g. your menu item). */ + open(anchor?: Element | null): void; + close(): void; + toggle(anchor?: Element | null): void; + /** Attach the signed-in user, or pass `null` after they sign out. */ + identify(identity: Identity | null): void; + setTheme(theme: WidgetTheme): void; + destroy(): void; + readonly isOpen: boolean; +} + +const locales: Record = { + en: { + launcher: 'Feedback', + title: 'Send feedback', + categories: 'Feedback type', + field: 'Your feedback', + placeholder: 'What happened, or what would make this better?', + pageNote: 'Includes the address of this page.', + send: 'Send', + sending: 'Sending', + retry: 'Try again', + close: 'Close', + sentTitle: 'Thanks, we got it', + sentBody: 'Your feedback went straight to the {product} team.', + another: 'Send another', + failed: 'Couldn’t send. Your message is still here, so you can try again.', + rateLimited: 'Too many reports from this network. Try again in a minute.', + poweredBy: 'Powered by', + bug: 'Problem', + feature_request: 'Idea', + docs: 'Docs', + other: 'Other', + }, + zh: { + launcher: '反馈', + title: '发送反馈', + categories: '反馈类型', + field: '你的反馈', + placeholder: '遇到了什么问题,或者有什么建议?', + pageNote: '会附带当前页面的地址。', + send: '发送', + sending: '发送中', + retry: '重试', + close: '关闭', + sentTitle: '收到了,谢谢', + sentBody: '你的反馈已直接送达 {product} 团队。', + another: '再发一条', + failed: '没有发送成功。内容还在,可以再试一次。', + rateLimited: '这个网络发送得太频繁了,请一分钟后再试。', + poweredBy: 'Powered by', + bug: '问题', + feature_request: '建议', + docs: '文档', + other: '其他', + }, +}; + +// [light, dark] button colours. A custom hex works in both themes. +const presets: Record = { + orange: ['#F58E02', '#F58E02'], + neutral: ['#18181B', '#FAFAFA'], + blue: ['#2563EB', '#2563EB'], + violet: ['#6D28D9', '#7C3AED'], +}; + +const defaultCategories: Category[] = ['bug', 'feature_request', 'other']; + +/** Dark text on light colours, white on dark ones (WCAG luminance). */ +export function readableOn(hex: string): string { + const m = /^#?([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(hex.trim()); + if (!m) return '#FFFFFF'; + const full = + m[1]!.length === 3 ? m[1]!.replace(/./g, (c) => c + c) : (m[1] as string); + const [r, g, b] = [0, 2, 4].map((i) => { + const v = parseInt(full.slice(i, i + 2), 16) / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }) as [number, number, number]; + const l = 0.2126 * r + 0.7152 * g + 0.0722 * b; + // Contrast against #18181B (L≈0.0093) beats white once L passes ~0.18. + return (l + 0.05) / 0.0593 > 1.05 / (l + 0.05) ? '#18181B' : '#FFFFFF'; +} + +function accent(color: WidgetColor = 'orange'): [string, string] { + const preset = presets[color]; + if (preset) return preset; + if (/^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) return [color, color]; + return presets.orange!; +} + +function pickLocale(locale?: WidgetLocale): WidgetLocale { + if (locale) return locale; + const lang = + typeof document === 'undefined' ? '' : document.documentElement.lang; + return /^zh\b/i.test(lang) ? 'zh' : 'en'; +} + +const logo = ``; +const icons = { + chat: '', + close: + '', + check: + '', + alert: + '', + spin: '', +}; + +const css = ` +:host{all:initial;position:fixed;z-index:2147483000} +:host,:host([data-theme=light]){--surface:#fff;--footer:#fafafa;--border:#e4e4e7;--field:#d4d4d8;--input:#fff;--text:#18181b;--muted:#52525b;--faint:#6b6b75;--handle:#d4d4d8;--shadow:0 16px 40px rgba(24,24,27,.14),0 2px 8px rgba(24,24,27,.06);--err:#b42318;--err-bg:#fef3f2;--err-border:#fecdca;--accent:var(--accent-light);--on-accent:var(--on-accent-light)} +:host([data-theme=dark]){--surface:#18181b;--footer:#151518;--border:#2e2e33;--field:#3f3f46;--input:#0f0f11;--text:#fafafa;--muted:#a1a1aa;--faint:#9b9ba5;--handle:#3f3f46;--shadow:0 16px 40px rgba(0,0,0,.55),0 2px 8px rgba(0,0,0,.4);--err:#fda29b;--err-bg:#2a1513;--err-border:#55201b;--accent:var(--accent-dark);--on-accent:var(--on-accent-dark)} +@media (prefers-color-scheme:dark){:host([data-theme=auto]){--surface:#18181b;--footer:#151518;--border:#2e2e33;--field:#3f3f46;--input:#0f0f11;--text:#fafafa;--muted:#a1a1aa;--faint:#9b9ba5;--handle:#3f3f46;--shadow:0 16px 40px rgba(0,0,0,.55),0 2px 8px rgba(0,0,0,.4);--err:#fda29b;--err-bg:#2a1513;--err-border:#55201b;--accent:var(--accent-dark);--on-accent:var(--on-accent-dark)}} +*{box-sizing:border-box} +[hidden]{display:none!important} +.root{font-family:var(--font);font-size:14px;line-height:1.45;color:var(--text);letter-spacing:normal;text-transform:none;text-align:left;-webkit-font-smoothing:antialiased} +button{font:inherit;color:inherit;cursor:pointer;margin:0} +button:focus-visible,a:focus-visible{outline:2px solid #71717a;outline-offset:2px} +svg{width:16px;height:16px;flex:none;display:block} +.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.launcher{position:fixed;bottom:24px;right:24px;height:44px;padding:0 18px 0 14px;border:0;border-radius:999px;background:var(--accent);color:var(--on-accent);font-size:14px;font-weight:600;display:inline-flex;align-items:center;gap:8px;box-shadow:0 6px 20px rgba(0,0,0,.18)} +.launcher svg{width:18px;height:18px} +.left .launcher{right:auto;left:24px} +.panel{position:fixed;width:360px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);animation:in .14s ease-out} +@keyframes in{from{opacity:0;transform:translateY(6px)}} +.handle{display:none} +header{display:flex;align-items:center;justify-content:space-between;padding:4px 4px 0 16px} +h2{margin:0;font-size:15px;font-weight:600;letter-spacing:-.01em} +.icon{width:44px;height:44px;border:0;border-radius:10px;background:transparent;color:var(--muted);display:inline-flex;align-items:center;justify-content:center} +.body{display:flex;flex-direction:column;gap:12px;padding:4px 16px 16px} +.chips{display:flex;flex-wrap:wrap;gap:6px} +.chip{height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--muted);font-size:13px;font-weight:500} +.chip[aria-pressed=true]{background:color-mix(in srgb,var(--accent) 14%,var(--surface));border-color:color-mix(in srgb,var(--accent) 72%,var(--text));color:var(--text)} +textarea{display:block;width:100%;height:120px;resize:none;margin:0;padding:10px 12px;border-radius:10px;border:1px solid var(--field);background:var(--input);color:var(--text);font:inherit;font-size:14px;line-height:1.5} +textarea::placeholder{color:var(--faint);opacity:1} +textarea:focus{outline:none;border-color:#71717a;box-shadow:0 0 0 3px rgba(113,113,122,.22)} +.note{margin:0;font-size:12px;color:var(--faint)} +.alert{display:flex;gap:10px;align-items:flex-start;margin:0;padding:10px 12px;border-radius:10px;border:1px solid var(--err-border);background:var(--err-bg);color:var(--err);font-size:13px} +.alert svg{margin-top:1px} +.sent{min-height:224px;padding:20px 24px 24px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;text-align:center} +.mark{width:44px;height:44px;margin-bottom:6px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:color-mix(in srgb,var(--accent) 72%,var(--text))} +.mark svg{width:20px;height:20px} +.sent p{margin:0} +.sent .title{font-size:15px;font-weight:600} +.sent .body-text{max-width:250px;font-size:13px;line-height:1.5;color:var(--muted)} +.ghost{margin-top:10px;height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--border);background:transparent;font-size:13px;font-weight:500} +footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px 10px 16px;border-top:1px solid var(--border);background:var(--footer)} +.brand{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--faint);text-decoration:none} +.brand svg{width:14px;height:14px} +.brand b{font-weight:600;color:var(--muted)} +.send{height:36px;padding:0 14px;border:0;border-radius:8px;background:var(--accent);color:var(--on-accent);font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:8px} +.send:disabled{cursor:default;opacity:.45} +.send[aria-busy=true]{opacity:1} +.send[aria-busy=true] kbd{display:none} +.send svg{width:14px;height:14px} +kbd{font:inherit;font-size:12px;font-weight:500;opacity:.65} +.spin{animation:spin .8s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} +.backdrop{position:fixed;inset:0;background:rgba(9,9,11,.45)} +@media (hover:none){kbd{display:none}} +@media (max-width:640px){ +.launcher{right:16px;bottom:24px;width:52px;height:52px;padding:0;justify-content:center} +.left .launcher{left:16px} +.launcher span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)} +.launcher svg{width:22px;height:22px} +.panel{left:0!important;right:0!important;bottom:0!important;top:auto!important;width:100%;max-width:none;max-height:calc(100vh - 48px);border-radius:16px 16px 0 0;border-bottom:0} +.handle{display:flex;justify-content:center;padding-top:8px} +.handle i{width:36px;height:4px;border-radius:2px;background:var(--handle)} +.chip{height:40px} +textarea{height:148px;font-size:16px} +.sent{min-height:252px} +footer{padding:12px 16px 20px} +.send{height:44px;padding:0 18px} +} +@media (prefers-reduced-motion:reduce){.panel{animation:none}.spin{animation-duration:2.4s}} +`; + +type Stage = 'compose' | 'sending' | 'sent' | 'error'; + +/** + * Mounts the Agentback feedback widget: a floating button (optional) and a + * panel where people describe a problem or an idea. Reports arrive in the + * Agentback inbox with source `web` and the page they were sent from. + */ +export function createFeedbackWidget( + options: FeedbackWidgetOptions, +): FeedbackWidget { + const client = new Agentback({ + projectKey: options.projectKey, + origin: options.origin, + fetch: options.fetch, + }); + const text: WidgetText = { + ...locales[pickLocale(options.locale)], + ...options.text, + }; + const types = + options.categories === false + ? [] + : (options.categories ?? defaultCategories) + .map((c) => + typeof c === 'string' + ? { value: c, label: text[c] } + : { value: c.value, label: c.label }, + ) + .filter((c) => allCategories.includes(c.value)); + const [light, dark] = accent(options.color); + let identity = signed(options.identity); + let stage: Stage = 'compose'; + let category: Category | undefined; + let anchor: Element | null = null; + let open = false; + + const host = document.createElement('div'); + host.setAttribute('data-agentback', ''); + host.dataset.theme = options.theme ?? 'auto'; + host.style.setProperty('--accent-light', light); + host.style.setProperty('--accent-dark', dark); + host.style.setProperty('--on-accent-light', readableOn(light)); + host.style.setProperty('--on-accent-dark', readableOn(dark)); + // Take the page's typeface so the widget reads as part of the product. + const pageFont = getComputedStyle(document.body).fontFamily; + host.style.setProperty( + '--font', + pageFont && !/^(serif|"?times)/i.test(pageFont) + ? pageFont + : 'system-ui,-apple-system,"Segoe UI",Roboto,sans-serif', + ); + const shadow = host.attachShadow({ mode: 'open' }); + const product = options.product?.trim(); + const sentBody = product + ? text.sentBody.replace('{product}', product) + : text.sentBody + .replace(/\s*\{product\}\s*/, ' ') + .replace(/ (?=[\u3000-\u9fff])|(?<=[\u3000-\u9fff]) /g, ''); + const mac = + typeof navigator !== 'undefined' && + /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent); + shadow.innerHTML = ` +
+ + + +
`; + const $ = (selector: string) => + shadow.querySelector(selector) as T; + const launcher = $('.launcher'); + const backdrop = $('.backdrop'); + const panel = $('.panel'); + const body = $('.body'); + const field = $('textarea'); + const alert = $('.alert'); + const sent = $('.sent'); + const send = $('.send'); + const chips = Array.from(shadow.querySelectorAll('.chip')); + launcher.hidden = options.launcher === false; + (options.container ?? document.body).appendChild(host); + + const narrow = () => window.matchMedia('(max-width: 640px)').matches; + + function render() { + const busy = stage === 'sending'; + body.hidden = stage === 'sent'; + sent.hidden = stage !== 'sent'; + send.hidden = stage === 'sent'; + alert.hidden = stage !== 'error'; + field.disabled = busy; + for (const chip of chips) { + chip.disabled = busy; + chip.setAttribute( + 'aria-pressed', + String(chip.dataset.value === category), + ); + } + send.disabled = busy || !field.value.trim(); + send.setAttribute('aria-busy', String(busy)); + send.querySelector('.label')!.textContent = busy + ? text.sending + : stage === 'error' + ? text.retry + : text.send; + send.querySelector('.spin')?.remove(); + if (busy) send.insertAdjacentHTML('afterbegin', icons.spin); + const fromLauncher = open && !anchor; + launcher.setAttribute('aria-expanded', String(fromLauncher)); + launcher.firstElementChild!.outerHTML = fromLauncher + ? icons.close + : icons.chat; + launcher.querySelector('span')!.textContent = fromLauncher + ? text.close + : text.launcher; + launcher.setAttribute( + 'aria-label', + fromLauncher ? text.close : text.launcher, + ); + } + + function place() { + const style = panel.style; + style.top = style.bottom = style.left = style.right = ''; + if (narrow()) return; + const width = panel.offsetWidth || 360; + if (!anchor) { + style.bottom = '80px'; + if (options.position === 'bottom-left') style.left = '24px'; + else style.right = '24px'; + return; + } + const rect = anchor.getBoundingClientRect(); + const left = Math.min( + Math.max(8, rect.right - width), + window.innerWidth - width - 8, + ); + style.left = `${left}px`; + if (window.innerHeight - rect.bottom >= panel.offsetHeight + 16) + style.top = `${rect.bottom + 8}px`; + else style.bottom = `${window.innerHeight - rect.top + 8}px`; + } + + function outside(event: Event) { + const path = event.composedPath(); + if (path.includes(host) || (anchor && path.includes(anchor))) return; + widget.close(); + } + + function trigger(event: MouseEvent) { + const target = (event.target as Element | null)?.closest?.( + '[data-agentback-open]', + ); + if (!target) return; + event.preventDefault(); + widget.toggle(target); + } + + async function submit() { + const message = field.value.trim(); + if (!message || stage === 'sending') return; + stage = 'sending'; + render(); + try { + const receipt = await client.send( + { + message, + ...(category ? { category } : {}), + ...(options.includePage === false + ? {} + : { page: location.origin + location.pathname }), + }, + { source: 'web', ...identityOptions(identity) }, + ); + stage = 'sent'; + field.value = ''; + category = undefined; + render(); + sent.querySelector('.another')!.focus(); + options.onSent?.(receipt); + } catch (error) { + stage = 'error'; + alert.querySelector('span')!.textContent = + error instanceof AgentbackError && error.status === 429 + ? text.rateLimited + : text.failed; + render(); + field.focus(); + } + } + + launcher.addEventListener('click', () => widget.toggle()); + $('.close').addEventListener('click', () => widget.close()); + backdrop.addEventListener('click', () => widget.close()); + sent.querySelector('.another')!.addEventListener('click', () => { + stage = 'compose'; + render(); + field.focus(); + }); + send.addEventListener('click', () => void submit()); + field.addEventListener('input', () => { + if (stage === 'error') stage = 'compose'; + render(); + }); + field.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + void submit(); + } + }); + panel.addEventListener('keydown', (event) => { + if (event.key === 'Escape') widget.close(); + }); + for (const chip of chips) + chip.addEventListener('click', () => { + const value = chip.dataset.value as Category; + category = category === value ? undefined : value; + render(); + }); + if (options.bindTriggers !== false) + document.addEventListener('click', trigger); + window.addEventListener('resize', place); + + const widget: FeedbackWidget = { + get isOpen() { + return open; + }, + open(target) { + anchor = target ?? null; + if (stage === 'sent') stage = 'compose'; + open = true; + panel.hidden = false; + backdrop.hidden = !narrow(); + launcher.style.visibility = narrow() ? 'hidden' : ''; + render(); + place(); + field.focus(); + // Defer so the click that opened the panel does not close it. + setTimeout(() => document.addEventListener('pointerdown', outside)); + }, + close() { + if (!open) return; + document.removeEventListener('pointerdown', outside); + open = false; + panel.hidden = true; + backdrop.hidden = true; + launcher.style.visibility = ''; + const returnTo = anchor ?? (launcher.hidden ? null : launcher); + render(); + (returnTo as HTMLElement | null)?.focus?.(); + }, + toggle(target) { + if (open && (target ?? null) === anchor) widget.close(); + else widget.open(target); + }, + identify(next) { + identity = signed(next ?? undefined); + }, + setTheme(theme) { + host.dataset.theme = theme; + }, + destroy() { + widget.close(); + document.removeEventListener('click', trigger); + window.removeEventListener('resize', place); + host.remove(); + }, + }; + render(); + return widget; +} + +// A web page cannot vouch for an account, so it needs your backend's signature. +function signed(identity?: Identity): Identity | undefined { + if (!identity) return undefined; + if (identity.account?.trim() && identity.signature) return identity; + console.warn( + 'Agentback: identity needs { account, signature } from signAccount on your backend; sending anonymously.', + ); + return undefined; +} + +function esc(value: string) { + return value.replace( + /[&<>"']/g, + (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + c + ]!, + ); +} diff --git a/src/widget.ts b/src/widget.ts new file mode 100644 index 0000000..201e74c --- /dev/null +++ b/src/widget.ts @@ -0,0 +1,70 @@ +// The script-tag build served at https://agentback.app/widget.js: +// +import { DEFAULT_ORIGIN } from './index.js'; +import { + createFeedbackWidget, + type FeedbackWidget, + type WidgetCategory, + type WidgetLocale, + type WidgetTheme, +} from './web.js'; + +declare global { + interface Window { + Agentback?: FeedbackWidget; + } +} + +// "bug:Report a bug,feature_request" relabels and orders; "false" hides. +function parseCategories(value?: string): false | WidgetCategory[] | undefined { + if (value === undefined) return undefined; + if (/^(false|none)$/i.test(value.trim())) return false; + return value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const [value, ...label] = part.split(':'); + return ( + label.length ? { value, label: label.join(':').trim() } : value + ) as WidgetCategory; + }); +} + +const script = document.currentScript as HTMLScriptElement | null; +const data = script?.dataset ?? {}; +// Served by an Agentback deployment, the widget reports to that deployment. +const served = script?.src ? new URL(script.src) : null; +const origin = + data.origin ?? + (served?.pathname === '/widget.js' ? served.origin : DEFAULT_ORIGIN); + +function start() { + if (window.Agentback) return; + if (!data.project) { + console.warn( + 'Agentback: add data-project="af_..." to the widget script tag.', + ); + return; + } + const widget = createFeedbackWidget({ + projectKey: data.project, + origin, + product: data.product, + color: data.color, + theme: data.theme as WidgetTheme | undefined, + launcher: data.launcher !== 'false', + position: data.position === 'bottom-left' ? 'bottom-left' : 'bottom-right', + categories: parseCategories(data.categories), + locale: data.locale as WidgetLocale | undefined, + identity: data.account + ? { account: data.account, signature: data.signature } + : undefined, + includePage: data.page !== 'false', + }); + window.Agentback = widget; + window.dispatchEvent(new CustomEvent('agentback:ready', { detail: widget })); +} + +if (document.body) start(); +else document.addEventListener('DOMContentLoaded', start, { once: true }); diff --git a/test/web.test.ts b/test/web.test.ts new file mode 100644 index 0000000..0353a52 --- /dev/null +++ b/test/web.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createFeedbackWidget, + readableOn, + type FeedbackWidget, + type FeedbackWidgetOptions, +} from '../src/web.js'; + +const projectKey = `af_${'a'.repeat(64)}`; + +function recorder(status = 201, body: unknown = { id: 'fb_1', status: 'new' }) { + const calls: { url: string; body: any }[] = []; + const fetch = vi.fn(async (url: string, init: RequestInit) => { + calls.push({ url, body: JSON.parse(init.body as string) }); + return Response.json(body, { status }); + }) as unknown as typeof globalThis.fetch; + return { calls, fetch }; +} + +let widget: FeedbackWidget | undefined; +afterEach(() => { + widget?.destroy(); + widget = undefined; + document.body.innerHTML = ''; + document.documentElement.lang = ''; +}); + +function mount(options: Partial = {}) { + const net = recorder(); + widget = createFeedbackWidget({ + projectKey, + origin: 'https://x.test', + fetch: net.fetch, + ...options, + }); + const root = document.querySelector('[data-agentback]')!.shadowRoot!; + const $ = (s: string) => + root.querySelector(s) as T; + const type = (value: string) => { + const field = $('textarea'); + field.value = value; + field.dispatchEvent(new Event('input')); + }; + const settle = () => new Promise((r) => setTimeout(r, 0)); + return { ...net, root, $, type, settle }; +} + +describe('feedback widget', () => { + it('sends a web report with the page and chosen type, then thanks the person', async () => { + history.replaceState(null, '', '/billing?token=secret#plan'); + const { $, type, settle, calls, root } = mount({ product: 'Acme Cloud' }); + expect($('.panel').hidden).toBe(true); + $('.launcher').click(); + expect($('.panel').hidden).toBe(false); + expect($('.launcher').getAttribute('aria-expanded')).toBe('true'); + + const chips = Array.from(root.querySelectorAll('.chip')); + expect(chips.map((c) => c.textContent)).toEqual([ + 'Problem', + 'Idea', + 'Other', + ]); + expect($('.send').disabled).toBe(true); + (chips[0] as HTMLButtonElement).click(); + expect(chips[0]!.getAttribute('aria-pressed')).toBe('true'); + type(' The usage chart stops at yesterday. '); + expect($('.send').disabled).toBe(false); + $('.send').click(); + await settle(); + + expect(calls[0]!.url).toBe(`https://x.test/f/${projectKey}`); + expect(calls[0]!.body).toEqual({ + message: 'The usage chart stops at yesterday.', + category: 'bug', + page: `${location.origin}/billing`, + source: 'web', + }); + expect($('.sent').hidden).toBe(false); + expect($('.sent .body-text').textContent).toBe( + 'Your feedback went straight to the Acme Cloud team.', + ); + expect($('textarea').value).toBe(''); + }); + + it('keeps the message and explains a rate limit', async () => { + const fetch = vi.fn(async () => + Response.json({ error: 'Too many reports.' }, { status: 429 }), + ) as unknown as typeof globalThis.fetch; + const { $, type, settle } = mount({ fetch }); + widget!.open(); + type('Broken'); + $('.send').click(); + await settle(); + expect($('.alert').hidden).toBe(false); + expect($('.alert').textContent).toContain('Try again in a minute'); + expect($('textarea').value).toBe('Broken'); + expect($('.send .label').textContent).toBe('Try again'); + }); + + it('relabels, reorders or hides the types and can leave out the page', async () => { + const relabelled = mount({ + categories: [{ value: 'feature_request', label: 'Suggestion' }, 'bug'], + }); + expect( + Array.from(relabelled.root.querySelectorAll('.chip')).map( + (c) => c.textContent, + ), + ).toEqual(['Suggestion', 'Problem']); + widget!.destroy(); + + const plain = mount({ categories: false, includePage: false }); + expect(plain.root.querySelector('.chips')).toBeNull(); + expect(plain.root.querySelector('.note')).toBeNull(); + widget!.open(); + plain.type('Hi'); + plain.$('.send').click(); + await plain.settle(); + expect(plain.calls[0]!.body).toEqual({ message: 'Hi', source: 'web' }); + }); + + it('opens from the product’s own menu when the button is turned off', () => { + document.body.innerHTML = + ''; + const { $ } = mount({ launcher: false }); + expect($('.launcher').hidden).toBe(true); + document.getElementById('menu')!.click(); + expect(widget!.isOpen).toBe(true); + document.getElementById('menu')!.click(); + expect(widget!.isOpen).toBe(false); + }); + + it('attaches a signed account and refuses an unsigned one', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { $, type, settle, calls } = mount({ + identity: { account: 'user_1' }, + }); + expect(warn).toHaveBeenCalledOnce(); + widget!.identify({ account: ' user_2 ', signature: 'f'.repeat(64) }); + widget!.open(); + type('Hello'); + $('.send').click(); + await settle(); + expect(calls[0]!.body).toMatchObject({ + account: 'user_2', + account_signature: 'f'.repeat(64), + }); + warn.mockRestore(); + }); + + it('speaks Chinese on a Chinese page and closes on Escape', () => { + document.documentElement.lang = 'zh-CN'; + const { $ } = mount(); + expect($('.launcher span').textContent).toBe('反馈'); + expect($('.sent .body-text').textContent).toBe('你的反馈已直接送达团队。'); + widget!.open(); + $('.panel').dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + expect(widget!.isOpen).toBe(false); + }); + + it('picks readable text for any brand colour', () => { + expect(readableOn('#F58E02')).toBe('#18181B'); + expect(readableOn('#2563EB')).toBe('#FFFFFF'); + expect(readableOn('#fff')).toBe('#18181B'); + const { root } = mount({ color: '#0EA5E9' }); + const host = root.host as HTMLElement; + expect(host.style.getPropertyValue('--accent-light')).toBe('#0EA5E9'); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index b5d278d..6650d96 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,5 +6,6 @@ "declaration": true, "outDir": "dist", "rootDir": "src" - } + }, + "exclude": ["src/widget.ts"] }