diff --git a/package-lock.json b/package-lock.json index 32d121561..d3fee4b05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "vite": "~8.0.16" }, "peerDependencies": { + "@microsoft/signalr": "^10.0.0", "dompurify": "^3.4.10", "marked": "^18.0.5", "marked-shiki": "^1.2.1", @@ -177,9 +178,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -197,9 +195,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -217,9 +212,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -237,9 +229,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1258,6 +1247,30 @@ "react": ">=16" } }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "peer": true, + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@microsoft/signalr/node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -1555,9 +1568,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1575,9 +1585,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1595,9 +1602,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1615,9 +1619,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1635,9 +1636,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1655,9 +1653,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1675,9 +1670,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1695,9 +1687,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1910,9 +1899,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1927,9 +1913,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1944,9 +1927,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1961,9 +1941,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1978,9 +1955,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1995,9 +1969,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2012,9 +1983,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2029,9 +1997,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2240,9 +2205,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2264,9 +2226,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2288,9 +2247,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2312,9 +2268,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2336,9 +2289,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2360,9 +2310,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2584,9 +2531,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2604,9 +2548,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2624,9 +2565,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2644,9 +2582,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2664,9 +2599,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2684,9 +2616,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2949,9 +2878,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2966,9 +2892,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2983,9 +2906,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3000,9 +2920,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3017,9 +2934,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3034,9 +2948,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3051,9 +2962,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3068,9 +2976,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3085,9 +2990,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3102,9 +3004,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3119,9 +3018,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3136,9 +3032,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3153,9 +3046,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5555,6 +5445,19 @@ "node": ">=14" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "peer": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -7743,6 +7646,16 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -8062,6 +7975,17 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "peer": true, + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "11.1.3", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.3.tgz", @@ -9047,7 +8971,6 @@ "arm" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -9065,7 +8988,6 @@ "arm64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -9083,7 +9005,6 @@ "arm" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -9101,7 +9022,6 @@ "arm64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -9119,7 +9039,6 @@ "riscv64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -9137,7 +9056,6 @@ "x64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -9155,7 +9073,6 @@ "riscv64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -9173,7 +9090,6 @@ "x64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -10296,9 +10212,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10320,9 +10233,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10344,9 +10254,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10368,9 +10275,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11438,6 +11342,52 @@ "license": "MIT", "optional": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "peer": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -12221,6 +12171,19 @@ "dev": true, "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -12236,7 +12199,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12329,6 +12291,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT", + "peer": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12557,6 +12526,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT", + "peer": true + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -13128,7 +13104,6 @@ "arm" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -13146,7 +13121,6 @@ "arm64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -13164,7 +13138,6 @@ "arm" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -13182,7 +13155,6 @@ "arm64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -13200,7 +13172,6 @@ "riscv64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -13218,7 +13189,6 @@ "x64" ], "dev": true, - "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -13236,7 +13206,6 @@ "riscv64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -13254,7 +13223,6 @@ "x64" ], "dev": true, - "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -13457,6 +13425,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT", + "peer": true + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -14564,6 +14539,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -14982,6 +14983,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -15382,7 +15394,6 @@ "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.3.0" diff --git a/package.json b/package.json index f7c9ee089..b2964e9fb 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "vite": "~8.0.16" }, "peerDependencies": { + "@microsoft/signalr": "^10.0.0", "dompurify": "^3.4.10", "marked": "^18.0.5", "marked-shiki": "^1.2.1", diff --git a/src/components/chat/chat-input.ts b/src/components/chat/chat-input.ts index 57f7996d9..33be768ab 100644 --- a/src/components/chat/chat-input.ts +++ b/src/components/chat/chat-input.ts @@ -16,6 +16,8 @@ import { bindIf, hasFiles, isEmpty, trimmedHtml } from '../common/util.js'; import IgcIconComponent from '../icon/icon.js'; import IgcTextareaComponent from '../textarea/textarea.js'; import type { ChatState } from './chat-state.js'; +import type { ISttClient } from './extras/stt-client-base.js'; +import { WebSpeechSttClient } from './extras/stt-client-webspeech.js'; import { styles } from './themes/input.base.css.js'; import { all } from './themes/input.js'; import { styles as shared } from './themes/shared/input/input.common.css.js'; @@ -38,6 +40,7 @@ type DefaultInputRenderers = { inputActionsStart: ChatTemplateRenderer; inputAttachments: ChatTemplateRenderer; fileUploadButton: ChatTemplateRenderer; + speechToTextButton: ChatTemplateRenderer; sendButton: ChatTemplateRenderer; }; @@ -88,6 +91,7 @@ export default class IgcChatInputComponent extends LitElement { private readonly _defaults: Readonly = Object.freeze({ fileUploadButton: () => this._renderFileUploadButton(), + speechToTextButton: () => this._renderSpeechToTextButton(), input: () => this._renderTextArea(), inputActions: () => this._renderActionsArea(), inputActionsEnd: () => nothing, @@ -135,6 +139,111 @@ export default class IgcChatInputComponent extends LitElement { return this._state.acceptedFileTypes; } + private _sttClient?: ISttClient; + + @state() + private isRecording = false; + + @state() + private isStopInProgress = false; + onPulseSignal = () => { + const el = this.renderRoot.querySelector( + 'igc-icon-button[part="speech-to-text"]' + ); + if (!el) return; + + el.classList.add('pulsate'); + el.addEventListener('animationend', () => el.classList.remove('pulsate'), { + once: true, + }); + }; + + onStartCountdown = (ms: number | null) => { + if (ms) { + const circle = this.renderRoot.querySelector( + 'svg.countdown-ring .ring-progress' + ); + if (!circle) return; + + circle.style.transition = 'none'; + circle.style.strokeDashoffset = '100'; + void circle.getBoundingClientRect(); // reflow + + circle.style.transition = `stroke-dashoffset ${ms}ms linear`; + circle.style.strokeDashoffset = '0'; + } else { + const circle = this.renderRoot.querySelector( + 'svg.countdown-ring .ring-progress' + ); + if (circle) { + circle.style.transition = 'none'; + circle.style.strokeDashoffset = '100'; + } + } + }; + + onStopInProgress = () => { + this.isStopInProgress = true; + }; + + onTranscript = (text: string) => { + this._state.inputValue = text; + }; + + onFinishedTranscribing = (finished: 'auto' | 'manual') => { + this.isStopInProgress = false; + this.isRecording = false; + if (finished === 'auto') { + this._sendMessage(); + } + }; + + private async _toggleMic(): Promise { + if (!this.isRecording) { + if (this._state.options?.speechToText?.serviceProvider === 'webspeech') { + this._sttClient = new WebSpeechSttClient( + this.onPulseSignal, + this.onStartCountdown, + this.onTranscript, + this.onStopInProgress, + this.onFinishedTranscribing + ); + } else if ( + this._state.options?.speechToText?.serviceProvider === 'backend' && + this._state.options?.speechToText?.serviceUri + ) { + const { BackendSttClient } = + await import('./extras/stt-client-backend.js'); + this._sttClient = new BackendSttClient( + this._state.options?.speechToText?.serviceUri ?? undefined, + this.onPulseSignal, + this.onStartCountdown, + this.onTranscript, + this.onStopInProgress, + this.onFinishedTranscribing + ); + } else { + // console.error('No STT service configured'); + } + + if (!this._sttClient) { + return; + } + + try { + await this._sttClient.start(this._state.options?.speechToText?.lang); + this.isRecording = true; + this.isStopInProgress = false; + } catch { + this._sttClient = undefined; + this.isRecording = false; + this.isStopInProgress = false; + } + } else { + this._sttClient?.stop(); + } + } + constructor() { super(); addThemingController(this, all, { themeChange: this._adoptPageStyles }); @@ -338,7 +447,12 @@ export default class IgcChatInputComponent extends LitElement { + + ${this.isRecording ? '🛑' : '🎤'} + + ${ + this.isRecording && !this.isStopInProgress + ? html` + + ` + : nothing + } + ` + : nothing + )}`; + } + private _renderSendButton() { const enabled = this._state.hasInputValue || this._state.hasInputAttachments; @@ -403,6 +556,9 @@ export default class IgcChatInputComponent extends LitElement {
${this._getRenderer('fileUploadButton')(ctx)}
+
+ ${this._getRenderer('speechToTextButton')(ctx)} +
${this._getRenderer('inputActionsStart')(ctx)}
diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index 22b2a9dcf..b8cea11fc 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -1,6 +1,6 @@ import { elementUpdated, expect, fixture } from '@open-wc/testing'; import { html, nothing } from 'lit'; -import { spy, stub, useFakeTimers } from 'sinon'; +import { fake, spy, stub, useFakeTimers } from 'sinon'; import { configureTheme } from '../../theming/config.js'; import type IgcIconButtonComponent from '../button/icon-button.js'; import IgcChipComponent from '../chip/chip.js'; @@ -227,6 +227,88 @@ describe('Chat', () => { ); }); + it('should apply `speakPlaceholder` while recording', async () => { + chat.options = { + inputPlaceholder: 'Type message here...', + speakPlaceholder: 'Listening...', + speechToText: { enable: true, serviceProvider: 'webspeech' }, + }; + await elementUpdated(chat); + + const { input } = getChatDOM(chat); + expect(input.textarea.placeholder).to.equal( + chat.options.inputPlaceholder + ); + + Reflect.set(input.self, 'isRecording', true); + input.self.requestUpdate(); + await elementUpdated(input.self); + + expect(input.textarea.placeholder).to.equal( + chat.options.speakPlaceholder + ); + }); + + it('should fallback to `inputPlaceholder` while recording if `speakPlaceholder` is not set', async () => { + chat.options = { + inputPlaceholder: 'Type message here...', + speechToText: { enable: true, serviceProvider: 'webspeech' }, + }; + await elementUpdated(chat); + + const { input } = getChatDOM(chat); + Reflect.set(input.self, 'isRecording', true); + input.self.requestUpdate(); + await elementUpdated(input.self); + + expect(input.textarea.placeholder).to.equal( + chat.options.inputPlaceholder + ); + }); + + it('should render the speech-to-text button only when enabled', async () => { + chat.options = { + speechToText: { enable: true, serviceProvider: 'webspeech' }, + }; + await elementUpdated(chat); + + let speechToTextButton = getChatDOM( + chat + ).input.self.renderRoot.querySelector( + 'igc-icon-button[part="speech-to-text"]' + ); + expect(speechToTextButton).not.to.be.null; + + chat.options = { + speechToText: { enable: false, serviceProvider: 'webspeech' }, + }; + await elementUpdated(chat); + + speechToTextButton = getChatDOM(chat).input.self.renderRoot.querySelector( + 'igc-icon-button[part="speech-to-text"]' + ); + expect(speechToTextButton).to.be.null; + }); + + it('should pass an accessibility audit with speech-to-text enabled and disabled', async () => { + chat.options = { + inputPlaceholder: 'Type message here...', + speakPlaceholder: 'Listening...', + speechToText: { enable: true, serviceProvider: 'webspeech' }, + }; + await elementUpdated(chat); + await expect(chat).to.be.accessible(); + await expect(chat).shadowDom.to.be.accessible(); + + chat.options = { + inputPlaceholder: 'Type message here...', + speechToText: { enable: false, serviceProvider: 'webspeech' }, + }; + await elementUpdated(chat); + await expect(chat).to.be.accessible(); + await expect(chat).shadowDom.to.be.accessible(); + }); + it('should enable/disable the send button properly', async () => { const { textarea, sendButton, fileInput } = getChatDOM(chat).input; @@ -349,6 +431,128 @@ describe('Chat', () => { }); }); + describe('Speech-to-text callbacks', () => { + beforeEach(async () => { + chat.options = { + speechToText: { enable: true, serviceProvider: 'webspeech' }, + }; + await elementUpdated(chat); + }); + + it('onPulseSignal() adds the pulsate class to the mic button', async () => { + const { input } = getChatDOM(chat); + const micButton = input.self.renderRoot.querySelector( + 'igc-icon-button[part="speech-to-text"]' + )!; + + input.self.onPulseSignal(); + + expect(micButton.classList.contains('pulsate')).to.be.true; + }); + + it('onStopInProgress() sets isStopInProgress to true', async () => { + const { input } = getChatDOM(chat); + + input.self.onStopInProgress(); + await elementUpdated(input.self); + + const micButton = input.self.renderRoot.querySelector( + 'igc-icon-button[part="speech-to-text"]' + )!; + expect(micButton.hasAttribute('disabled')).to.be.true; + }); + + it('onTranscript() updates the input value', async () => { + const { input } = getChatDOM(chat); + + input.self.onTranscript('hello world'); + await elementUpdated(chat); + + expect(input.textarea.value).to.equal('hello world'); + }); + + it('onFinishedTranscribing("manual") clears the recording state', async () => { + const { input } = getChatDOM(chat); + Reflect.set(input.self, 'isRecording', true); + Reflect.set(input.self, 'isStopInProgress', true); + input.self.requestUpdate(); + await elementUpdated(input.self); + + input.self.onFinishedTranscribing('manual'); + await elementUpdated(input.self); + + const micButton = input.self.renderRoot.querySelector( + 'igc-icon-button[part="speech-to-text"]' + )!; + expect(micButton.hasAttribute('disabled')).to.be.false; + }); + + it('onFinishedTranscribing("auto") clears recording state and sends the message', async () => { + const { input } = getChatDOM(chat); + Reflect.set(input.self, 'isRecording', true); + input.self.requestUpdate(); + await elementUpdated(input.self); + + // Set a pending input value so _sendMessage actually dispatches a message. + input.self.onTranscript('voice message'); + + const sendSpy = spy(input.self as any, '_sendMessage'); + input.self.onFinishedTranscribing('auto'); + + expect(sendSpy.calledOnce).to.be.true; + sendSpy.restore(); + }); + + it('onStartCountdown(ms) starts the SVG ring animation', async () => { + const { input } = getChatDOM(chat); + Reflect.set(input.self, 'isRecording', true); + input.self.requestUpdate(); + await elementUpdated(input.self); + + const circle = input.self.renderRoot.querySelector( + 'svg.countdown-ring .ring-progress' + )!; + expect(circle).not.to.be.null; + + input.self.onStartCountdown(3000); + + expect(circle.style.strokeDashoffset).to.equal('0'); + }); + + it('onStartCountdown(null) resets the SVG ring', async () => { + const { input } = getChatDOM(chat); + Reflect.set(input.self, 'isRecording', true); + input.self.requestUpdate(); + await elementUpdated(input.self); + + const circle = input.self.renderRoot.querySelector( + 'svg.countdown-ring .ring-progress' + )!; + + // First start the animation, then reset it. + input.self.onStartCountdown(3000); + input.self.onStartCountdown(null); + + expect(circle.style.strokeDashoffset).to.equal('100'); + }); + + it('clicking the mic button while recording stops the current client', async () => { + const { input } = getChatDOM(chat); + const stopFake = fake(); + Reflect.set(input.self, '_sttClient', { stop: stopFake }); + Reflect.set(input.self, 'isRecording', true); + input.self.requestUpdate(); + await elementUpdated(input.self); + + const micButton = input.self.renderRoot.querySelector( + 'igc-icon-button[part="speech-to-text"]' + )!; + simulateClick(micButton); + + expect(stopFake.calledOnce).to.be.true; + }); + }); + describe('Slots', () => { const getSlottedElements = (slotName: string) => { const prefixSlot = chat.shadowRoot?.querySelector( diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 35adc6cf0..6ec89dc4f 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -178,6 +178,8 @@ const Slots = setSlots( * @csspart input-actions-end - Styles the group of actions at the end of the input. * @csspart file-upload-container - Styles the container for the file upload input. * @csspart file-upload - Styles the file upload input itself. + * @csspart speech-to-text-container - Styles the container for the speech to text buttons. + * @csspart speech-to-text - Styles the speech to text buttons. * @csspart send-button-container - Styles the container around the send button. * @csspart send-button - Styles the send button. * @@ -544,6 +546,8 @@ export default class IgcChatComponent extends EventEmitterMixin< input-actions-end, file-upload-container, file-upload, + speech-to-text-container, + speech-to-text, send-button-container, send-button" > diff --git a/src/components/chat/extras/stt-client-backend.ts b/src/components/chat/extras/stt-client-backend.ts new file mode 100644 index 000000000..9df57f49e --- /dev/null +++ b/src/components/chat/extras/stt-client-backend.ts @@ -0,0 +1,187 @@ +import * as signalR from '@microsoft/signalr'; +import { BaseSttClient } from './stt-client-base.js'; + +const HUB_TRANSCRIBE_AUDIO_CHUNK = 'TranscribeAudioChunk'; +const HUB_RECEIVE_TRANSCRIPT = 'ReceiveTranscript'; +const HUB_COMPLETE_TRANSCRIBE = 'FinalizeTranscription'; + +export class BackendSttClient extends BaseSttClient { + private hubConnection?: signalR.HubConnection; + private mediaRecorder?: MediaRecorder; + private isStopInProgress = false; + private isStopCompleted = false; + private stopHubTimeout: ReturnType | null = null; + + constructor( + hubUrl: string, + onPulseSignal: () => void, + onStartCountdown: (ms: number | null) => void, + onTranscript: (text: string) => void, + onStopInProgress: () => void, + onFinishedTranscribing: (finish: 'auto' | 'manual') => void + ) { + super( + onPulseSignal, + onStartCountdown, + onTranscript, + onStopInProgress, + onFinishedTranscribing + ); + this.hubUrl = hubUrl; + } + + private hubUrl: string; + + async start(language = 'en-US') { + if (this.isRecording) { + return; + } + + this.onTranscript(''); + const transcript = { + confirmed: '', + tentative: '', + }; + + this.hubConnection = this.createHubConnection(); + + this.hubConnection.on( + HUB_RECEIVE_TRANSCRIPT, + (data: { + transcription: string; + isFinal: boolean; + isCompleted: boolean; + }) => { + this.handleTranscriptEvent(transcript, data); + } + ); + + await this.hubConnection.start(); + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + sampleRate: 48000, + noiseSuppression: true, + echoCancellation: true, + autoGainControl: true, + }, + }); + this.mediaRecorder = new MediaRecorder(stream, { + mimeType: 'audio/webm;codecs=opus', + }); + + this.mediaRecorder.ondataavailable = async (event) => { + this.sendForTranscription(event.data, language); + if (this.isStopInProgress) { + await this.hubConnection?.invoke(HUB_COMPLETE_TRANSCRIBE); + } + }; + + this.mediaRecorder.start(100); + this.isRecording = true; + this.isStopInProgress = false; + this.isStopCompleted = false; + this.resetSilenceTimer(); + this.restartGracePeriod(); + } + + stop() { + if (this.isStopInProgress) { + return; + } + + this.isStopInProgress = true; + this.onStopInProgress(); + this.clearSilenceTimer(); + this.clearSilenceGraceTimer(); + this.clearStopHubTimeout(); + this.mediaRecorder?.stop(); + + this.stopHubTimeout = setTimeout(() => { + this.stopHubConnection(); + this.stopHubTimeout = null; + }, 1500); + } + + private async sendForTranscription(data: Blob, language: string) { + if ( + data.size > 0 && + this.hubConnection?.state === signalR.HubConnectionState.Connected + ) { + const buffer = await data.arrayBuffer(); + const base64Audio = btoa(String.fromCharCode(...new Uint8Array(buffer))); + try { + await this.hubConnection.invoke( + HUB_TRANSCRIBE_AUDIO_CHUNK, + base64Audio, + language + ); + } catch { + //console.error("STT invoke failed:", err); //TODO + this.stop(); + } + } + } + + private handleTranscriptEvent( + currentTranscript: any, + newTranscriptData: any + ) { + currentTranscript.tentative = newTranscriptData.transcription; + this.onTranscript( + currentTranscript.confirmed + currentTranscript.tentative + ); + this.resetSilenceTimer(); + if (newTranscriptData.isFinal) { + currentTranscript.confirmed += currentTranscript.tentative; + } + + this.onPulseSignal(); + + if (this.isCountdownRunning) { + this.onStartCountdown(null); + this.isCountdownRunning = false; + } + + this.restartGracePeriod(); + + if (newTranscriptData.isCompleted) { + this.stopHubConnection(); + } + } + + private createHubConnection() { + const hubConnection = new signalR.HubConnectionBuilder() + .withUrl(this.hubUrl) + .configureLogging(signalR.LogLevel.Warning) + .build(); + + return hubConnection; + } + + private stopHubConnection() { + if (this.isStopInProgress && !this.isStopCompleted) { + this.isRecording = false; + this.mediaRecorder?.stream.getTracks().forEach((t) => { + t.stop(); + }); + this.mediaRecorder = undefined; + this.hubConnection?.off(HUB_RECEIVE_TRANSCRIPT); + this.hubConnection?.stop(); + this.isStopCompleted = true; + this.isStopInProgress = false; + this.clearStopHubTimeout(); + + this.onFinishedTranscribing(this.isAutoFinished ? 'auto' : 'manual'); + this.isAutoFinished = false; + } + } + + private clearStopHubTimeout() { + if (this.stopHubTimeout) { + clearTimeout(this.stopHubTimeout); + this.stopHubTimeout = null; + } + } +} diff --git a/src/components/chat/extras/stt-client-base.ts b/src/components/chat/extras/stt-client-base.ts new file mode 100644 index 000000000..6cc8902bf --- /dev/null +++ b/src/components/chat/extras/stt-client-base.ts @@ -0,0 +1,87 @@ +export interface ISttClient { + /** Start recording and transcribing */ + start(language?: string): Promise; + + /** Stop recording/transcribing */ + stop(): void; + + /** Whether recording is currently active */ + readonly isRecording: boolean; + + /** Signal emitted when audio activity is detected */ + readonly onPulseSignal: () => void; + + /** Called when the countdown should start or stop (pass null to stop) */ + readonly onStartCountdown: (ms: number | null) => void; + + /** Called when a partial or final transcript is produced */ + readonly onTranscript: (text: string) => void; + + /** Called when stop is in progress (manual or automatic) */ + readonly onStopInProgress: () => void; + + /** Called when transcription fully finishes */ + readonly onFinishedTranscribing: (finish: 'auto' | 'manual') => void; +} + +export abstract class BaseSttClient implements ISttClient { + protected static readonly SILENCE_TIMEOUT_MS = 4000; + protected static readonly SILENCE_GRACE_PERIOD = 1000; + + protected silenceTimeout: ReturnType | null = null; + protected silenceGraceTimeout: ReturnType | null = null; + protected isCountdownRunning = false; + protected isAutoFinished = false; + + isRecording = false; + + constructor( + public onPulseSignal: () => void, + public onStartCountdown: (ms: number | null) => void, + public onTranscript: (text: string) => void, + public onStopInProgress: () => void, + public onFinishedTranscribing: (finish: 'auto' | 'manual') => void + ) {} + + abstract start(language?: string): Promise; + abstract stop(): void; + + /** Clears the silence timeout */ + protected clearSilenceTimer() { + if (this.silenceTimeout) { + clearTimeout(this.silenceTimeout); + this.silenceTimeout = null; + } + } + + /** Clears the silence grace timeout */ + protected clearSilenceGraceTimer() { + if (this.silenceGraceTimeout) { + clearTimeout(this.silenceGraceTimeout); + this.silenceGraceTimeout = null; + } + } + + /** Resets silence timer and auto-stop trigger */ + protected resetSilenceTimer() { + this.clearSilenceTimer(); + this.silenceTimeout = setTimeout(() => { + this.isAutoFinished = true; + this.stop(); + }, BaseSttClient.SILENCE_TIMEOUT_MS); + } + + /** Restarts grace period before countdown starts */ + protected restartGracePeriod() { + if (this.silenceGraceTimeout) { + clearTimeout(this.silenceGraceTimeout); + } + + this.silenceGraceTimeout = setTimeout(() => { + this.isCountdownRunning = true; + this.onStartCountdown( + BaseSttClient.SILENCE_TIMEOUT_MS - BaseSttClient.SILENCE_GRACE_PERIOD + ); + }, BaseSttClient.SILENCE_GRACE_PERIOD); + } +} diff --git a/src/components/chat/extras/stt-client-webspeech.ts b/src/components/chat/extras/stt-client-webspeech.ts new file mode 100644 index 000000000..1cccc5980 --- /dev/null +++ b/src/components/chat/extras/stt-client-webspeech.ts @@ -0,0 +1,107 @@ +import { BaseSttClient } from './stt-client-base.js'; + +export class WebSpeechSttClient extends BaseSttClient { + private recognition?: SpeechRecognition; + + async start(language = 'en-US') { + if (this.isRecording) { + return; + } + + this.onTranscript(''); + + const SpeechRecognition = + window.SpeechRecognition ?? window.webkitSpeechRecognition; + if (!SpeechRecognition) { + throw new Error('Web Speech API not supported in this browser.'); + } + this.recognition = new SpeechRecognition(); + this.recognition.continuous = true; + this.recognition.interimResults = true; + this.recognition.lang = language; + + this.recognition.onresult = (event: SpeechRecognitionEvent) => { + this.handleTranscriptEvent(event); + }; + + this.recognition.onerror = () => { + this.stop(); + }; + + this.recognition?.start(); + + this.isRecording = true; + this.resetSilenceTimer(); + this.restartGracePeriod(); + } + + stop() { + if (!this.isRecording) { + return; + } + + this.clearSilenceTimer(); + this.clearSilenceGraceTimer(); + if (this.isCountdownRunning) { + this.onStartCountdown(null); + this.isCountdownRunning = false; + } + + this.recognition?.stop(); + this.isRecording = false; + this.onFinishedTranscribing(this.isAutoFinished ? 'auto' : 'manual'); + this.isAutoFinished = false; + } + + private handleTranscriptEvent(event: any) { + let transcript = ''; + for (let i = 0; i < event.results.length; i++) { + transcript += event.results[i][0].transcript; + } + this.onTranscript(transcript); + this.resetSilenceTimer(); + this.onPulseSignal(); + if (this.isCountdownRunning) { + this.onStartCountdown(null); + this.isCountdownRunning = false; + } + this.restartGracePeriod(); + } +} + +//Fix. Add own interface declaration for SpeechRecognition +declare global { + interface Window { + SpeechRecognition?: typeof SpeechRecognition; + webkitSpeechRecognition?: typeof SpeechRecognition; + } + + // The browser’s Web Speech API type (simplified) + var SpeechRecognition: { + new (): SpeechRecognition; + }; + + interface SpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + abort(): void; + onaudioend?: (this: SpeechRecognition, ev: Event) => any; + onaudiostart?: (this: SpeechRecognition, ev: Event) => any; + onend?: (this: SpeechRecognition, ev: Event) => any; + onerror?: (this: SpeechRecognition, ev: Event) => any; + onnomatch?: (this: SpeechRecognition, ev: Event) => any; + onresult?: (this: SpeechRecognition, ev: SpeechRecognitionEvent) => any; + onsoundend?: (this: SpeechRecognition, ev: Event) => any; + onsoundstart?: (this: SpeechRecognition, ev: Event) => any; + onspeechend?: (this: SpeechRecognition, ev: Event) => any; + onspeechstart?: (this: SpeechRecognition, ev: Event) => any; + onstart?: (this: SpeechRecognition, ev: Event) => any; + } + + interface SpeechRecognitionEvent extends Event { + readonly results: SpeechRecognitionResultList; + } +} diff --git a/src/components/chat/extras/stt-clients.spec.ts b/src/components/chat/extras/stt-clients.spec.ts new file mode 100644 index 000000000..21c9a390d --- /dev/null +++ b/src/components/chat/extras/stt-clients.spec.ts @@ -0,0 +1,281 @@ +import { expect } from '@open-wc/testing'; +import { fake, useFakeTimers } from 'sinon'; +import { BaseSttClient } from './stt-client-base.js'; +import { WebSpeechSttClient } from './stt-client-webspeech.js'; + +const SILENCE_TIMEOUT = 4000; +const GRACE_PERIOD = 1000; + +// Minimal concrete implementation of BaseSttClient for testing. +class ConcreteClient extends BaseSttClient { + async start(): Promise { + if (this.isRecording) return; + this.isRecording = true; + this.resetSilenceTimer(); + this.restartGracePeriod(); + } + + stop(): void { + this.clearSilenceTimer(); + this.clearSilenceGraceTimer(); + if (this.isCountdownRunning) { + this.onStartCountdown(null); + this.isCountdownRunning = false; + } + this.isRecording = false; + this.onFinishedTranscribing(this.isAutoFinished ? 'auto' : 'manual'); + this.isAutoFinished = false; + } +} + +// Minimal mock of the browser SpeechRecognition API. +class MockRecognition { + continuous = false; + interimResults = false; + lang = ''; + onresult: ((event: any) => void) | undefined; + onerror: (() => void) | undefined; + start = fake(); + stop = fake(); +} + +function makeWebSpeechClient( + opts: { + onPulse?: () => void; + onCountdown?: (ms: number | null) => void; + onTranscript?: (text: string) => void; + onStopInProgress?: () => void; + onFinished?: (f: 'auto' | 'manual') => void; + } = {} +) { + const noop = () => {}; + return new WebSpeechSttClient( + opts.onPulse ?? noop, + opts.onCountdown ?? noop, + opts.onTranscript ?? noop, + opts.onStopInProgress ?? noop, + opts.onFinished ?? noop + ); +} + +describe('BaseSttClient', () => { + const noop = () => {}; + let clock: ReturnType; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('initializes with isRecording set to false', () => { + const client = new ConcreteClient(noop, noop, noop, noop, noop); + expect(client.isRecording).to.be.false; + }); + + it('auto-stops and fires onFinishedTranscribing("auto") after silence timeout', async () => { + const onFinished = fake(); + const client = new ConcreteClient(noop, noop, noop, noop, onFinished); + await client.start(); + + clock.tick(SILENCE_TIMEOUT + 100); + + expect(onFinished.calledOnce).to.be.true; + expect(onFinished.firstCall.args[0]).to.equal('auto'); + }); + + it('stop() prevents auto-stop and fires onFinishedTranscribing("manual")', async () => { + const onFinished = fake(); + const client = new ConcreteClient(noop, noop, noop, noop, onFinished); + await client.start(); + + client.stop(); + clock.tick(SILENCE_TIMEOUT + 100); + + // Called exactly once by stop(), not again by the timer. + expect(onFinished.callCount).to.equal(1); + expect(onFinished.firstCall.args[0]).to.equal('manual'); + }); + + it('fires onStartCountdown after the grace period', async () => { + const onCountdown = fake(); + const client = new ConcreteClient(noop, onCountdown, noop, noop, noop); + await client.start(); + + clock.tick(GRACE_PERIOD + 100); + + expect(onCountdown.calledWith(SILENCE_TIMEOUT - GRACE_PERIOD)).to.be.true; + }); + + it('stop() resets the countdown when it is running', async () => { + const onCountdown = fake(); + const client = new ConcreteClient(noop, onCountdown, noop, noop, noop); + await client.start(); + + clock.tick(GRACE_PERIOD + 100); + client.stop(); + + expect(onCountdown.calledWith(null)).to.be.true; + }); + + it('start() is a no-op when already recording', async () => { + const client = new ConcreteClient(noop, noop, noop, noop, noop); + await client.start(); + const firstIsRecording = client.isRecording; + await client.start(); + + expect(firstIsRecording).to.be.true; + expect(client.isRecording).to.be.true; + }); +}); + +describe('WebSpeechSttClient', () => { + let clock: ReturnType; + let recognition!: MockRecognition; + + beforeEach(() => { + clock = useFakeTimers(); + recognition = new MockRecognition(); + (window as any).SpeechRecognition = class extends MockRecognition { + constructor() { + super(); + recognition = this; + } + }; + delete (window as any).webkitSpeechRecognition; + }); + + afterEach(() => { + clock.restore(); + delete (window as any).SpeechRecognition; + }); + + it('start() creates a recognition instance with correct settings', async () => { + const client = makeWebSpeechClient(); + await client.start('de-DE'); + + expect(client.isRecording).to.be.true; + expect(recognition.continuous).to.be.true; + expect(recognition.interimResults).to.be.true; + expect(recognition.lang).to.equal('de-DE'); + expect(recognition.start.calledOnce).to.be.true; + }); + + it('start() is a no-op when already recording', async () => { + const client = makeWebSpeechClient(); + await client.start(); + await client.start(); + + expect(recognition.start.calledOnce).to.be.true; + }); + + it('start() throws when SpeechRecognition is not available', async () => { + delete (window as any).SpeechRecognition; + delete (window as any).webkitSpeechRecognition; + const client = makeWebSpeechClient(); + + try { + await client.start(); + expect.fail('Expected an error to be thrown'); + } catch (e) { + expect((e as Error).message).to.include('not supported'); + } + }); + + it('stop() clears recording state and fires onFinishedTranscribing("manual")', async () => { + const onFinished = fake(); + const client = makeWebSpeechClient({ onFinished }); + await client.start(); + + client.stop(); + + expect(client.isRecording).to.be.false; + expect(recognition.stop.calledOnce).to.be.true; + expect(onFinished.calledWith('manual')).to.be.true; + }); + + it('stop() is a no-op when not recording', () => { + const onFinished = fake(); + const client = makeWebSpeechClient({ onFinished }); + + client.stop(); + + expect(onFinished.called).to.be.false; + expect(recognition.stop.called).to.be.false; + }); + + it('onresult handler updates transcript and fires onPulseSignal', async () => { + const onTranscript = fake(); + const onPulse = fake(); + const client = makeWebSpeechClient({ onTranscript, onPulse }); + await client.start(); + + recognition.onresult!({ results: [[{ transcript: 'hello world' }]] }); + + expect(onTranscript.calledWith('hello world')).to.be.true; + expect(onPulse.calledOnce).to.be.true; + }); + + it('onresult handler concatenates transcripts from multiple results', async () => { + const onTranscript = fake(); + const client = makeWebSpeechClient({ onTranscript }); + await client.start(); + + recognition.onresult!({ + results: [[{ transcript: 'hello ' }], [{ transcript: 'world' }]], + }); + + expect(onTranscript.lastCall.args[0]).to.equal('hello world'); + }); + + it('onerror handler stops the client', async () => { + const onFinished = fake(); + const client = makeWebSpeechClient({ onFinished }); + await client.start(); + + recognition.onerror!(); + + expect(client.isRecording).to.be.false; + expect(onFinished.called).to.be.true; + }); + + it('auto-stops after silence timeout with onFinishedTranscribing("auto")', async () => { + const onFinished = fake(); + const client = makeWebSpeechClient({ onFinished }); + await client.start(); + + clock.tick(SILENCE_TIMEOUT + 100); + + expect(onFinished.calledWith('auto')).to.be.true; + }); + + it('stop() resets countdown when it is running', async () => { + const onCountdown = fake(); + const client = makeWebSpeechClient({ onCountdown }); + await client.start(); + + clock.tick(GRACE_PERIOD + 100); + expect(onCountdown.calledWith(SILENCE_TIMEOUT - GRACE_PERIOD)).to.be.true; + + client.stop(); + expect(onCountdown.calledWith(null)).to.be.true; + }); + + it('falls back to webkitSpeechRecognition when SpeechRecognition is absent', async () => { + delete (window as any).SpeechRecognition; + (window as any).webkitSpeechRecognition = class extends MockRecognition { + constructor() { + super(); + recognition = this; + } + }; + + const client = makeWebSpeechClient(); + await client.start(); + + expect(recognition.start.calledOnce).to.be.true; + expect(client.isRecording).to.be.true; + }); +}); diff --git a/src/components/chat/themes/input.base.scss b/src/components/chat/themes/input.base.scss index 5831e345f..83d453352 100644 --- a/src/components/chat/themes/input.base.scss +++ b/src/components/chat/themes/input.base.scss @@ -69,3 +69,47 @@ igc-icon-button { margin-inline: rem(12px); } } + +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.3); } + 100% { transform: scale(1); } +} + +.pulsate { + animation: pulse 0.2s ease-in-out; +} + +.stop-btn-wrapper { + position: relative; + display: inline-block; +} + +igc-icon-button[part="speech-to-text"][disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +.countdown-ring { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform: rotate(-90deg); + pointer-events: none; +} + +.ring-bg { + fill: none; + stroke: transparent; +} + +.ring-progress { + fill: none; + stroke: rgb(0 0 0 / 50%); + stroke-width: 3; + stroke-dasharray: 100; + stroke-dashoffset: 100; + transition: stroke-dashoffset linear; +} diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 58cdb5e1a..e5702098a 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -117,6 +117,11 @@ export interface IgcChatOptions { * Provides a hint to the user about what they can type (e.g. "Type a message..."). */ inputPlaceholder?: string; + /** + * Optional placeholder text for the chat input area. + * Provides a hint to the user about what they can speak to the microphone and get transcribed text (e.g. "Speak..."). + */ + speakPlaceholder?: string; /** * Suggested text snippets or quick replies that can be shown as user-selectable options. */ @@ -163,6 +168,19 @@ export interface IgcChatOptions { * An object containing a collection of custom renderers for different parts of the chat UI. */ renderers?: ChatRenderers; + + /** + * Configuration options for enabling and customizing speech-to-text functionality. + * If provided, it enables a button in the chat input area that allows users to dictate messages using their voice. + */ + speechToText?: SpeechToTextOptions; +} + +export interface SpeechToTextOptions { + enable: boolean; + lang?: string; + serviceProvider: 'webspeech' | 'backend'; + serviceUri?: string; } /* jsonAPIPlainObject */ @@ -220,6 +238,10 @@ export interface ChatRenderers { * Custom renderer for the file upload button in the input area. */ fileUploadButton?: ChatTemplateRenderer; + /** + * Custom renderer for the speech to text button in the input area. + */ + speechToTextButton?: ChatTemplateRenderer; /** * Custom renderer for the main chat input field. */ diff --git a/stories/chat.stories.ts b/stories/chat.stories.ts index 0432eea37..031933050 100644 --- a/stories/chat.stories.ts +++ b/stories/chat.stories.ts @@ -405,11 +405,18 @@ export const Basic: Story = { { headerText: 'IgniteUI Web Components Support', inputPlaceholder: 'Ask about installation, components, theming…', + speakPlaceholder: 'Speak...', suggestions: [ 'How do I install the library?', 'What themes are available?', 'How do events work?', ], + speechToText: { + enable: true, + lang: 'en-US', + serviceProvider: 'webspeech', // 'webspeech' | 'backend' + serviceUri: undefined, //e.g. 'https://localhost:5000/sttHub', + }, renderers: { messageContent: async ({ message }) => _markdownRenderer(message), }, @@ -476,8 +483,8 @@ const templatesOptions: IgcChatOptions = { messageActions: _messageActionsTemplate, attachmentHeader: () => nothing, inputActionsStart: () => html` - 🎤📍 `, inputActionsEnd: (ctx: ChatRenderContext) => html` @@ -497,7 +504,7 @@ export const Chat_Templates: Story = { docs: { description: { story: - 'The same library support chat with full renderer customization applied: custom message author header (avatar + name), a thumbs-up action on agent messages, a custom "Send" button replacing the default, a voice input button, and a custom suggestion prefix. Header and action slots are also projected via light DOM.', + 'The same library support chat with full renderer customization applied: custom message author header (avatar + name), a thumbs-up action on agent messages, a custom "Send" button replacing the default, a send location button, and a custom suggestion prefix. Header and action slots are also projected via light DOM.', }, }, },