diff --git a/package-lock.json b/package-lock.json index 901f8ca24df..fbd5b974ff7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24329,6 +24329,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -24346,6 +24347,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -24357,13 +24359,15 @@ "version": "9.2.2", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -24381,6 +24385,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -24396,6 +24401,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minipass": "^7.0.4" }, @@ -24407,13 +24413,15 @@ "version": "1.1.0", "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/npm/node_modules/@npmcli/agent": { "version": "3.0.0", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", @@ -24430,6 +24438,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^4.0.0", @@ -24479,6 +24488,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/map-workspaces": "^4.0.1", "@npmcli/package-json": "^6.0.1", @@ -24498,6 +24508,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "semver": "^7.3.5" }, @@ -24510,6 +24521,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/promise-spawn": "^8.0.0", "ini": "^5.0.0", @@ -24529,6 +24541,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "npm-bundled": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" @@ -24545,6 +24558,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/name-from-folder": "^3.0.0", "@npmcli/package-json": "^6.0.0", @@ -24560,6 +24574,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "cacache": "^19.0.0", "json-parse-even-better-errors": "^4.0.0", @@ -24576,6 +24591,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/git": "^6.0.0", "@npmcli/installed-package-contents": "^3.0.0", @@ -24607,6 +24623,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -24616,6 +24633,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -24625,6 +24643,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/git": "^6.0.0", "glob": "^10.2.2", @@ -24643,6 +24662,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "which": "^5.0.0" }, @@ -24655,6 +24675,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -24667,6 +24688,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -24676,6 +24698,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/node-gyp": "^4.0.0", "@npmcli/package-json": "^6.0.0", @@ -24694,6 +24717,7 @@ "inBundle": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=14" } @@ -24703,6 +24727,7 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -24712,6 +24737,7 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@sigstore/protobuf-specs": "^0.4.1", "tuf-js": "^3.0.1" @@ -24725,6 +24751,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": "^16.14.0 || >=18.0.0" } @@ -24734,6 +24761,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -24743,6 +24771,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 14" } @@ -24752,6 +24781,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -24761,6 +24791,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -24772,25 +24803,29 @@ "version": "2.0.0", "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/npm/node_modules/archy": { "version": "1.0.0", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/balanced-match": { "version": "1.0.2", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/bin-links": { "version": "5.0.0", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "cmd-shim": "^7.0.0", "npm-normalize-package-bin": "^4.0.0", @@ -24807,6 +24842,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" }, @@ -24819,6 +24855,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -24828,6 +24865,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", @@ -24851,6 +24889,7 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", + "peer": true, "engines": { "node": ">=18" } @@ -24860,6 +24899,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "bin": { "mkdirp": "dist/cjs/src/bin.js" }, @@ -24875,6 +24915,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -24892,6 +24933,7 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", + "peer": true, "engines": { "node": ">=18" } @@ -24901,6 +24943,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -24913,6 +24956,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": ">=10" } @@ -24928,6 +24972,7 @@ ], "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -24937,6 +24982,7 @@ "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "ip-regex": "^5.0.0" }, @@ -24949,6 +24995,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "string-width": "^4.2.3", "strip-ansi": "^6.0.1" @@ -24962,6 +25009,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -24971,6 +25019,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -24982,19 +25031,22 @@ "version": "1.1.4", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/common-ancestor-path": { "version": "1.0.1", "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/npm/node_modules/cross-spawn": { "version": "7.0.6", "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -25009,6 +25061,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -25024,6 +25077,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "bin": { "cssesc": "bin/cssesc" }, @@ -25036,6 +25090,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -25053,6 +25108,7 @@ "dev": true, "inBundle": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.3.1" } @@ -25061,13 +25117,15 @@ "version": "0.2.0", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/emoji-regex": { "version": "8.0.0", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/encoding": { "version": "0.1.13", @@ -25075,6 +25133,7 @@ "inBundle": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -25084,6 +25143,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -25092,19 +25152,22 @@ "version": "2.0.3", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/exponential-backoff": { "version": "3.1.2", "dev": true, "inBundle": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/npm/node_modules/fastest-levenshtein": { "version": "1.0.16", "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.9.1" } @@ -25114,6 +25177,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" @@ -25130,6 +25194,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minipass": "^7.0.3" }, @@ -25142,6 +25207,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -25161,13 +25227,15 @@ "version": "4.2.11", "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/npm/node_modules/hosted-git-info": { "version": "8.1.0", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "lru-cache": "^10.0.1" }, @@ -25179,13 +25247,15 @@ "version": "4.2.0", "dev": true, "inBundle": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/npm/node_modules/http-proxy-agent": { "version": "7.0.2", "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -25199,6 +25269,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -25213,6 +25284,7 @@ "inBundle": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -25225,6 +25297,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minimatch": "^9.0.0" }, @@ -25237,6 +25310,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.19" } @@ -25246,6 +25320,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -25255,6 +25330,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/package-json": "^6.0.0", "npm-package-arg": "^12.0.0", @@ -25273,6 +25349,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" @@ -25286,6 +25363,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -25298,6 +25376,7 @@ "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "cidr-regex": "^4.1.1" }, @@ -25310,6 +25389,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -25318,13 +25398,15 @@ "version": "2.0.0", "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/npm/node_modules/jackspeak": { "version": "3.4.3", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", + "peer": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -25339,13 +25421,15 @@ "version": "1.1.0", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/json-parse-even-better-errors": { "version": "4.0.0", "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -25355,6 +25439,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -25366,25 +25451,29 @@ "node >= 0.2.0" ], "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/just-diff": { "version": "6.0.2", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/just-diff-apply": { "version": "5.5.0", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/libnpmaccess": { "version": "9.0.0", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "npm-package-arg": "^12.0.0", "npm-registry-fetch": "^18.0.1" @@ -25398,6 +25487,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/arborist": "^8.0.1", "@npmcli/installed-package-contents": "^3.0.0", @@ -25417,6 +25507,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/arborist": "^8.0.1", "@npmcli/run-script": "^9.0.1", @@ -25438,6 +25529,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/arborist": "^8.0.1" }, @@ -25450,6 +25542,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^2.0.0", "npm-registry-fetch": "^18.0.1" @@ -25463,6 +25556,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^2.0.0", "npm-registry-fetch": "^18.0.1" @@ -25476,6 +25570,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/arborist": "^8.0.1", "@npmcli/run-script": "^9.0.1", @@ -25491,6 +25586,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "ci-info": "^4.0.0", "normalize-package-data": "^7.0.0", @@ -25510,6 +25606,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "npm-registry-fetch": "^18.0.1" }, @@ -25522,6 +25619,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^2.0.0", "npm-registry-fetch": "^18.0.1" @@ -25535,6 +25633,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/git": "^6.0.1", "@npmcli/run-script": "^9.0.1", @@ -25550,13 +25649,15 @@ "version": "10.4.3", "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/npm/node_modules/make-fetch-happen": { "version": "14.0.3", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", @@ -25579,6 +25680,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -25588,6 +25690,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -25603,6 +25706,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -25612,6 +25716,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minipass": "^7.0.3" }, @@ -25624,6 +25729,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", @@ -25641,6 +25747,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minipass": "^3.0.0" }, @@ -25653,6 +25760,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -25665,6 +25773,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minipass": "^3.0.0" }, @@ -25677,6 +25786,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -25689,6 +25799,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minipass": "^3.0.0" }, @@ -25701,6 +25812,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -25713,6 +25825,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "minipass": "^7.1.2" }, @@ -25725,6 +25838,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -25736,13 +25850,15 @@ "version": "2.1.3", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/mute-stream": { "version": "2.0.0", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -25752,6 +25868,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", @@ -25776,6 +25893,7 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", + "peer": true, "engines": { "node": ">=18" } @@ -25785,6 +25903,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "bin": { "mkdirp": "dist/cjs/src/bin.js" }, @@ -25800,6 +25919,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -25817,6 +25937,7 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", + "peer": true, "engines": { "node": ">=18" } @@ -25826,6 +25947,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "abbrev": "^3.0.0" }, @@ -25841,6 +25963,7 @@ "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "hosted-git-info": "^8.0.0", "semver": "^7.3.5", @@ -25855,6 +25978,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -25864,6 +25988,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "npm-normalize-package-bin": "^4.0.0" }, @@ -25876,6 +26001,7 @@ "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "semver": "^7.1.1" }, @@ -25888,6 +26014,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -25897,6 +26024,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "hosted-git-info": "^8.0.0", "proc-log": "^5.0.0", @@ -25912,6 +26040,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "ignore-walk": "^7.0.0" }, @@ -25924,6 +26053,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "npm-install-checks": "^7.1.0", "npm-normalize-package-bin": "^4.0.0", @@ -25939,6 +26069,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "npm-registry-fetch": "^18.0.0", "proc-log": "^5.0.0" @@ -25952,6 +26083,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/redact": "^3.0.0", "jsonparse": "^1.3.1", @@ -25971,6 +26103,7 @@ "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -25980,6 +26113,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -25991,13 +26125,15 @@ "version": "1.0.1", "dev": true, "inBundle": true, - "license": "BlueOak-1.0.0" + "license": "BlueOak-1.0.0", + "peer": true }, "node_modules/npm/node_modules/pacote": { "version": "19.0.1", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "@npmcli/git": "^6.0.0", "@npmcli/installed-package-contents": "^3.0.0", @@ -26029,6 +26165,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "json-parse-even-better-errors": "^4.0.0", "just-diff": "^6.0.0", @@ -26043,6 +26180,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -26052,6 +26190,7 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", + "peer": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -26068,6 +26207,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -26081,6 +26221,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -26090,6 +26231,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -26099,6 +26241,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -26108,6 +26251,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -26117,6 +26261,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -26130,6 +26275,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "read": "^4.0.0" }, @@ -26141,6 +26287,7 @@ "version": "0.12.0", "dev": true, "inBundle": true, + "peer": true, "bin": { "qrcode-terminal": "bin/qrcode-terminal.js" } @@ -26150,6 +26297,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "mute-stream": "^2.0.0" }, @@ -26162,6 +26310,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -26171,6 +26320,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "json-parse-even-better-errors": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" @@ -26184,6 +26334,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4" } @@ -26193,13 +26344,15 @@ "dev": true, "inBundle": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/npm/node_modules/semver": { "version": "7.7.2", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -26212,6 +26365,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -26224,6 +26378,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -26233,6 +26388,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": ">=14" }, @@ -26245,6 +26401,7 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", @@ -26262,6 +26419,7 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@sigstore/protobuf-specs": "^0.4.0" }, @@ -26274,6 +26432,7 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -26283,6 +26442,7 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", @@ -26300,6 +26460,7 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", @@ -26314,6 +26475,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -26324,6 +26486,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -26338,6 +26501,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -26352,6 +26516,7 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -26362,6 +26527,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -26371,13 +26537,15 @@ "version": "2.5.0", "dev": true, "inBundle": true, - "license": "CC-BY-3.0" + "license": "CC-BY-3.0", + "peer": true }, "node_modules/npm/node_modules/spdx-expression-parse": { "version": "4.0.0", "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -26387,19 +26555,22 @@ "version": "3.0.21", "dev": true, "inBundle": true, - "license": "CC0-1.0" + "license": "CC0-1.0", + "peer": true }, "node_modules/npm/node_modules/sprintf-js": { "version": "1.1.3", "dev": true, "inBundle": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/npm/node_modules/ssri": { "version": "12.0.0", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minipass": "^7.0.3" }, @@ -26412,6 +26583,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -26427,6 +26599,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -26441,6 +26614,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -26454,6 +26628,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -26466,6 +26641,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -26478,6 +26654,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -26495,6 +26672,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minipass": "^3.0.0" }, @@ -26507,6 +26685,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -26519,6 +26698,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": ">=8" } @@ -26528,6 +26708,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -26541,6 +26722,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -26552,19 +26734,22 @@ "version": "0.2.0", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/tiny-relative-date": { "version": "1.3.0", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/tinyglobby": { "version": "0.2.14", "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" @@ -26581,6 +26766,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -26595,6 +26781,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -26607,6 +26794,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -26616,6 +26804,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "@tufjs/models": "3.0.1", "debug": "^4.3.6", @@ -26630,6 +26819,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^9.0.5" @@ -26643,6 +26833,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "unique-slug": "^5.0.0" }, @@ -26655,6 +26846,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "imurmurhash": "^0.1.4" }, @@ -26666,13 +26858,15 @@ "version": "1.0.2", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/validate-npm-package-license": { "version": "3.0.4", "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" @@ -26683,6 +26877,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -26693,6 +26888,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -26701,13 +26897,15 @@ "version": "3.0.1", "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/npm/node_modules/which": { "version": "5.0.0", "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "isexe": "^3.1.1" }, @@ -26723,6 +26921,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": ">=16" } @@ -26732,6 +26931,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -26750,6 +26950,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -26767,6 +26968,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -26782,6 +26984,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -26793,13 +26996,15 @@ "version": "9.2.2", "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { "version": "5.1.2", "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -26817,6 +27022,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -26832,6 +27038,7 @@ "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" @@ -26844,7 +27051,8 @@ "version": "4.0.0", "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/nth-check": { "version": "2.1.1", diff --git a/src/app/chatbot/chat.service.ts b/src/app/chatbot/chat.service.ts deleted file mode 100644 index 00cac5a0f8a..00000000000 --- a/src/app/chatbot/chat.service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { inject, Injectable } from '@angular/core'; -import { ChatMessage } from './chat'; -import { firstValueFrom } from 'rxjs'; -import { HttpClient } from '@angular/common/http'; - -@Injectable({ providedIn: 'root' }) -export class ChatService { - protected chatEndpoint = '/api/chat-gpt'; - private http = inject(HttpClient); - protected model: string = 'gpt-3.5-turbo'; - - /** - * Sends a message to the chat-gpt endpoint. - * @param messages The conversation history. - * @returns A promise that resolves to the response from the chat-gpt endpoint. - */ - async sendMessage(messages: ChatMessage[]): Promise { - const payload = { - messages: messages.map((msg) => ({ - role: msg.role, - content: msg.content - })), - model: this.model - }; - try { - const response = await firstValueFrom(this.http.post(`${this.chatEndpoint}`, payload)); - return this.processResponse(response.choices[0].message.content); - } catch (error) { - console.error('Error calling chat endpoint:', error); - throw error; - } - } - - processResponse(response: string): string { - return response; - } - - /** - * Generates a short, concise title for a chat based on the first message. - * @param message The first user message content. - * @returns A promise that resolves to the generated title. - */ - async generateChatTitle(message: string): Promise { - const prompt = `Generate a short, concise title (max 5 words) for a chat that starts with this message: "${message}". Respond only with the title, no quotes or extra text. If the language of the message is not English, return the title in that language.`; - const messages: ChatMessage[] = [ - new ChatMessage( - 'system', - 'You are a helpful assistant that generates short titles for chat conversations.', - '' - ), - new ChatMessage('user', prompt, '') - ]; - return this.sendMessage(messages); - } -} diff --git a/src/app/chatbot/chatbot.component.spec.ts b/src/app/chatbot/chatbot.component.spec.ts index 22aa7a0f24a..595cbe7d14f 100644 --- a/src/app/chatbot/chatbot.component.spec.ts +++ b/src/app/chatbot/chatbot.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ChatbotComponent } from './chatbot.component'; import { ChatbotService } from './chatbot.service'; -import { AwsBedRockService } from './awsBedRock.service'; import { ConfigService } from '../../assets/wise5/services/configService'; import { DataService } from '../services/data.service'; import { ProjectService } from '../../assets/wise5/services/projectService'; @@ -9,12 +8,13 @@ import { BreakpointObserver } from '@angular/cdk/layout'; import { of, throwError } from 'rxjs'; import { Chat, ChatMessage } from './chat'; import { provideHttpClient } from '@angular/common/http'; +import { OpenAiChatService } from '../services/chat/openAiChat.service'; describe('ChatbotComponent', () => { let component: ChatbotComponent; let fixture: ComponentFixture; let chatbotService: jasmine.SpyObj; - let awsBedRockService: jasmine.SpyObj; + let chatService: jasmine.SpyObj; let configService: jasmine.SpyObj; let dataService: jasmine.SpyObj; let projectService: jasmine.SpyObj; @@ -41,7 +41,7 @@ describe('ChatbotComponent', () => { 'updateChat', 'deleteChat' ]); - const awsBedRockServiceSpy = jasmine.createSpyObj('AwsBedRockService', [ + const chatServiceSpy = jasmine.createSpyObj('OpenAiChatService', [ 'sendMessage', 'generateChatTitle' ]); @@ -66,7 +66,7 @@ describe('ChatbotComponent', () => { imports: [ChatbotComponent], providers: [ { provide: ChatbotService, useValue: chatbotServiceSpy }, - { provide: AwsBedRockService, useValue: awsBedRockServiceSpy }, + { provide: OpenAiChatService, useValue: chatServiceSpy }, { provide: ConfigService, useValue: configServiceSpy }, { provide: DataService, useValue: dataServiceSpy }, { provide: ProjectService, useValue: projectServiceSpy }, @@ -76,7 +76,7 @@ describe('ChatbotComponent', () => { }).compileComponents(); chatbotService = TestBed.inject(ChatbotService) as jasmine.SpyObj; - awsBedRockService = TestBed.inject(AwsBedRockService) as jasmine.SpyObj; + chatService = TestBed.inject(OpenAiChatService) as jasmine.SpyObj; configService = TestBed.inject(ConfigService) as jasmine.SpyObj; dataService = TestBed.inject(DataService) as jasmine.SpyObj; projectService = TestBed.inject(ProjectService) as jasmine.SpyObj; @@ -134,8 +134,8 @@ describe('ChatbotComponent', () => { const assistantResponse = 'Hi there!'; component['userInput'] = userMessage; - awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); - awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve('New Title')); + chatService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); + spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title')); await component['sendMessage'](); expect(component['messages'].length).toBe(2); @@ -156,12 +156,12 @@ describe('ChatbotComponent', () => { // First user message (only system message exists initially) component['messages'] = []; - awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); - awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve(newTitle)); + chatService.sendMessage.and.returnValue(Promise.resolve(assistantResponse)); + spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve(newTitle)); await component['sendMessage'](); - expect(awsBedRockService.generateChatTitle).toHaveBeenCalledWith(userMessage); + expect(component['generateChatTitle']).toHaveBeenCalledWith(userMessage); expect(component['currentChat']?.title).toBe(newTitle); expect(chatbotService.updateChat).toHaveBeenCalled(); }); @@ -171,7 +171,7 @@ describe('ChatbotComponent', () => { await component['sendMessage'](); - expect(awsBedRockService.sendMessage).not.toHaveBeenCalled(); + expect(chatService.sendMessage).not.toHaveBeenCalled(); expect(component['messages'].length).toBe(0); }); @@ -181,7 +181,7 @@ describe('ChatbotComponent', () => { await component['sendMessage'](); - expect(awsBedRockService.sendMessage).not.toHaveBeenCalled(); + expect(chatService.sendMessage).not.toHaveBeenCalled(); }); it('should not send messages when no current chat', async () => { @@ -190,12 +190,12 @@ describe('ChatbotComponent', () => { await component['sendMessage'](); - expect(awsBedRockService.sendMessage).not.toHaveBeenCalled(); + expect(chatService.sendMessage).not.toHaveBeenCalled(); }); it('should handle errors when sending messages', async () => { component['userInput'] = 'Hello'; - awsBedRockService.sendMessage.and.returnValue(Promise.reject(new Error('API error'))); + chatService.sendMessage.and.returnValue(Promise.reject(new Error('API error'))); await component['sendMessage'](); @@ -336,8 +336,8 @@ describe('ChatbotComponent', () => { it('should send message on Enter key press', () => { component['userInput'] = 'Hello'; - awsBedRockService.sendMessage.and.returnValue(Promise.resolve('Response')); - awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve('New Title')); + chatService.sendMessage.and.returnValue(Promise.resolve('Response')); + spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title')); const event = new KeyboardEvent('keypress', { key: 'Enter' }); spyOn(event, 'preventDefault'); @@ -356,7 +356,7 @@ describe('ChatbotComponent', () => { component['handleKeyPress'](event); expect(event.preventDefault).not.toHaveBeenCalled(); - expect(awsBedRockService.sendMessage).not.toHaveBeenCalled(); + expect(chatService.sendMessage).not.toHaveBeenCalled(); }); }); diff --git a/src/app/chatbot/chatbot.component.ts b/src/app/chatbot/chatbot.component.ts index 47429e881fd..65735b53011 100644 --- a/src/app/chatbot/chatbot.component.ts +++ b/src/app/chatbot/chatbot.component.ts @@ -10,16 +10,16 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { BreakpointObserver } from '@angular/cdk/layout'; -import { skip, Subscription } from 'rxjs'; +import { Subscription } from 'rxjs'; import { ChatbotService } from './chatbot.service'; import { ConfigService } from '../../assets/wise5/services/configService'; import { DataService } from '../services/data.service'; import { Chat, ChatMessage } from './chat'; -import { AwsBedRockService } from './awsBedRock.service'; import { ProjectService } from '../../assets/wise5/services/projectService'; import { MarkdownComponent } from 'ngx-markdown'; import { ChatHistoryDialogComponent } from './chat-history-dialog.component'; -import { MatDividerModule } from '@angular/material/divider'; +import { ChatService } from '../services/chat/chat.service'; +import { OpenAiChatService } from '../services/chat/openAiChat.service'; @Component({ imports: [ @@ -42,7 +42,7 @@ import { MatDividerModule } from '@angular/material/divider'; export class ChatbotComponent { private breakpointObserver = inject(BreakpointObserver); private chatbotService: ChatbotService = inject(ChatbotService); - private awsBedRockService: AwsBedRockService = inject(AwsBedRockService); + private chatService: ChatService = inject(OpenAiChatService); private configService: ConfigService = inject(ConfigService); private dataService: DataService = inject(DataService); private projectService = inject(ProjectService); @@ -112,7 +112,7 @@ export class ChatbotComponent { this.loading = true; this.scrollToBottom(); try { - const response = await this.awsBedRockService.sendMessage(this.messages); + const response = await this.chatService.sendMessage(this.messages); this.messages.push( new ChatMessage('assistant', response, this.dataService.getCurrentNode().id) ); @@ -154,7 +154,7 @@ export class ChatbotComponent { */ private async generateAndSetChatTitle(firstUserMessage: ChatMessage): Promise { try { - let newTitle = await this.awsBedRockService.generateChatTitle(firstUserMessage.content); + let newTitle = await this.generateChatTitle(firstUserMessage.content); // Remove surrounding quotes if any newTitle = newTitle.replace(/^["'](.*)["']$/, '$1').trim(); if (newTitle) { @@ -165,6 +165,24 @@ export class ChatbotComponent { } } + /** + * Generates a short, concise title for a chat based on the first message. + * @param message The first user message content. + * @returns A promise that resolves to the generated title. + */ + async generateChatTitle(message: string): Promise { + const prompt = `Generate a short, concise title (max 5 words) for a chat that starts with this message: "${message}". Respond only with the title, no quotes or extra text. If the language of the message is not English, return the title in that language.`; + const messages: ChatMessage[] = [ + new ChatMessage( + 'system', + 'You are a helpful assistant that generates short titles for chat conversations.', + '' + ), + new ChatMessage('user', prompt, '') + ]; + return this.chatService.sendMessage(messages); + } + protected switchToChat(chat: Chat): void { this.currentChat = chat; this.messages = [...chat.messages]; diff --git a/src/app/chatbot/awsBedRock.service.ts b/src/app/services/chat/awsBedRockChat.service.ts similarity index 86% rename from src/app/chatbot/awsBedRock.service.ts rename to src/app/services/chat/awsBedRockChat.service.ts index efa3fec6d78..5ebbb1314f3 100644 --- a/src/app/chatbot/awsBedRock.service.ts +++ b/src/app/services/chat/awsBedRockChat.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { ChatService } from './chat.service'; @Injectable({ providedIn: 'root' }) -export class AwsBedRockService extends ChatService { +export class AwsBedRockChatService extends ChatService { protected chatEndpoint = '/api/aws-bedrock/chat'; protected model: string = 'google.gemma-3-27b-it'; diff --git a/src/app/services/chat/chat.service.ts b/src/app/services/chat/chat.service.ts new file mode 100644 index 00000000000..e18b8d1622d --- /dev/null +++ b/src/app/services/chat/chat.service.ts @@ -0,0 +1,37 @@ +import { inject } from '@angular/core'; +import { ChatMessage } from '../../chatbot/chat'; +import { firstValueFrom } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; + +export abstract class ChatService { + protected abstract chatEndpoint: string; + protected abstract model: string; + + private http = inject(HttpClient); + + /** + * Sends a message to the chat endpoint. + * @param messages The conversation history. + * @returns A promise that resolves to the response from the chat endpoint. + */ + async sendMessage(messages: ChatMessage[]): Promise { + const payload = { + messages: messages.map((msg) => ({ + role: msg.role, + content: msg.content + })), + model: this.model + }; + try { + const response = await firstValueFrom(this.http.post(`${this.chatEndpoint}`, payload)); + return this.processResponse(response.choices[0].message.content); + } catch (error) { + console.error('Error calling chat endpoint:', error); + throw error; + } + } + + processResponse(response: string): string { + return response; + } +} diff --git a/src/app/services/chat/openAiChat.service.ts b/src/app/services/chat/openAiChat.service.ts new file mode 100644 index 00000000000..78b8f6a2d10 --- /dev/null +++ b/src/app/services/chat/openAiChat.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; +import { ChatService } from './chat.service'; + +@Injectable({ providedIn: 'root' }) +export class OpenAiChatService extends ChatService { + protected chatEndpoint = '/api/chat-gpt'; + protected model: string = 'gpt-4o'; +} diff --git a/src/app/services/localStorageService.ts b/src/app/services/localStorageService.ts new file mode 100644 index 00000000000..08f2cf8926e --- /dev/null +++ b/src/app/services/localStorageService.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class LocalStorageService { + setItem(key: string, value: any): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error('Error saving to local storage', e); + } + } + + getItem(key: string): any { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : null; + } catch (e) { + console.error('Error reading from local storage', e); + return null; + } + } + + removeItem(key: string): void { + try { + localStorage.removeItem(key); + } catch (e) { + console.error('Error removing from local storage', e); + } + } + + clear(): void { + try { + localStorage.clear(); + } catch (e) { + console.error('Error clearing local storage', e); + } + } +} diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html index 1d4c7c197d3..3733027bc3b 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.html @@ -93,12 +93,27 @@ class="summary" /> + } @else if (component?.type === 'OpenResponse' && hasStudentWork) { + + + + + } @else if (component?.type === 'Discussion' && hasStudentWork) {
+ ; @@ -31,6 +32,7 @@ describe('ComponentSummaryComponent', () => { AnnotationService, ComponentServiceLookupService, CRaterService, + ProjectService, SummaryService ), MockProvider(TeacherDataService, { diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts index bba31d87244..c763245b586 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/component-summary/component-summary.component.ts @@ -15,6 +15,9 @@ import { IdeasSummaryComponent } from '../../../directives/teacher-summary-displ import { MatchSummaryDisplayComponent } from '../../../directives/teacher-summary-display/match-summary-display/match-summary-display.component'; import { MatCardModule } from '@angular/material/card'; import { CRaterService } from '../../../services/cRaterService'; +import { OpenResponseAiSummaryComponent } from '../../../directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component'; +import { ProjectService } from '../../../services/projectService'; +import { DiscussionAiSummaryComponent } from '../../../directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { @@ -28,6 +31,7 @@ import { DiscussionSummaryComponent } from '../../../directives/teacher-summary- @Component({ imports: [ ComponentCompletionComponent, + DiscussionAiSummaryComponent, DiscussionSummaryComponent, IdeasSummaryComponent, MatButtonModule, @@ -35,6 +39,7 @@ import { DiscussionSummaryComponent } from '../../../directives/teacher-summary- MatIconModule, MatchSummaryDisplayComponent, MilestoneReportButtonComponent, + OpenResponseAiSummaryComponent, NgTemplateOutlet, PeerGroupButtonComponent, TeacherSummaryDisplayComponent @@ -62,6 +67,7 @@ export class ComponentSummaryComponent { private cRaterService: CRaterService, private dataService: TeacherDataService, private dialog: MatDialog, + private projectService: ProjectService, private summaryService: SummaryService ) {} @@ -93,6 +99,9 @@ export class ComponentSummaryComponent { (this.hasScoresSummary && this.hasScoreAnnotation) || this.hasIdeaRubricData || ['Match', 'Discussion'].includes(this.component?.type); + if (this.component?.type === 'OpenResponse') { + this.hasSummaryData = this.projectService.getProject().ai?.enabled; + } } private setSource(): void { diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html new file mode 100644 index 00000000000..f7ecfda29cc --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html @@ -0,0 +1,24 @@ +@if (hasStudentResponses) { +
+
+ + @if (generatingSummary) { + + } +
+ @if (newSummaryAvailable) { + *New responses since last summary + } +
+ @if (summary) { + +
+ Summary generated {{ summaryDate | date: 'short' }} from + {{ latestComponentStates.length }} responses +
+ } +} @else { +
No student responses
+} diff --git a/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts new file mode 100644 index 00000000000..e6ffea6cdd0 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.ts @@ -0,0 +1,89 @@ +import { Component, inject, Input } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { ChatMessage } from '../../../../../app/chatbot/chat'; +import { TeacherDataService } from '../../../services/teacherDataService'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { LocalStorageService } from '../../../../../app/services/localStorageService'; +import { MarkdownComponent } from 'ngx-markdown'; +import { DatePipe } from '@angular/common'; +import { TeacherProjectService } from '../../../services/teacherProjectService'; +import { ChatService } from '../../../../../app/services/chat/chat.service'; +import { OpenAiChatService } from '../../../../../app/services/chat/openAiChat.service'; + +/** + * Abstract base class for components that use an LLM to summarize student responses. + */ +@Component({ + imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + templateUrl: './ai-summary.component.html' +}) +export abstract class AiSummaryComponent { + @Input() componentId: string; + @Input() nodeId: string; + @Input() periodId: number; + + private chatService: ChatService = inject(OpenAiChatService); + protected dataService: TeacherDataService = inject(TeacherDataService); + private localStorageService: LocalStorageService = inject(LocalStorageService); + protected projectService: TeacherProjectService = inject(TeacherProjectService); + + protected generatingSummary: boolean = false; + protected hasStudentResponses: boolean = false; + protected latestComponentStates: any[] = []; + protected newSummaryAvailable: boolean = false; + protected summary: string; + protected summaryDate: Date; + + ngOnInit(): void { + this.latestComponentStates = this.getLatestComponentStates(); + this.hasStudentResponses = this.latestComponentStates.length > 0; + if (this.hasStudentResponses) { + this.summary = this.localStorageService.getItem(this.getSummaryKey()) || ''; + const summaryTime = this.localStorageService.getItem(this.getSummaryTimeKey()) || 0; + this.summaryDate = new Date(summaryTime); + this.newSummaryAvailable = summaryTime > 0 && this.getLastResponseTime() > summaryTime; + } + } + + protected getLatestComponentStates(): any[] { + return this.dataService + .getComponentStatesByComponentId(this.componentId) + .filter((state) => state.periodId === this.periodId || this.periodId === -1) + .sort((a, b) => a.serverSaveTime - b.serverSaveTime); + } + + private getLastResponseTime(): number { + return this.latestComponentStates.reduce( + (max, state) => Math.max(max, state.serverSaveTime), + 0 + ); + } + + protected async generateSummary(): Promise { + this.generatingSummary = true; + const prompt = this.projectService.getComponent(this.nodeId, this.componentId).prompt; + this.summary = await this.chatService.sendMessage([ + new ChatMessage('system', this.getSystemPrompt(prompt), this.nodeId), + new ChatMessage('user', this.getStudentResponses(), this.nodeId) + ]); + this.localStorageService.setItem(this.getSummaryKey(), this.summary); + const summaryTime = new Date().getTime(); + this.localStorageService.setItem(this.getSummaryTimeKey(), summaryTime); + this.summaryDate = new Date(summaryTime); + this.generatingSummary = false; + this.newSummaryAvailable = false; + } + + protected abstract getStudentResponses(): string; + + protected abstract getSystemPrompt(prompt: string): string; + + private getSummaryKey(): string { + return `component-summary-${this.periodId}-${this.nodeId}-${this.componentId}`; + } + + private getSummaryTimeKey(): string { + return `component-summary-time-${this.periodId}-${this.nodeId}-${this.componentId}`; + } +} diff --git a/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts new file mode 100644 index 00000000000..c1fb900838d --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/discussion-ai-summary/discussion-ai-summary.component.ts @@ -0,0 +1,51 @@ +import { Component } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MarkdownComponent } from 'ngx-markdown'; +import { AiSummaryComponent } from '../ai-summary/ai-summary.component'; +import { DatePipe } from '@angular/common'; + +interface Thread { + id: number; + post: string; + replies: string[]; +} + +/** + * Uses an LLM to summarize student discussion threads. + */ +@Component({ + imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + selector: 'discussion-ai-summary', + templateUrl: '../ai-summary/ai-summary.component.html' +}) +export class DiscussionAiSummaryComponent extends AiSummaryComponent { + protected getSystemPrompt(prompt: string): string { + return `You are a teacher who is summarizing students' discussion threads, which include posts and replies to the following question: "${prompt}". + Each thread is in the format: PostReply 1Reply 2. + In the same language as the question, provide a summary of the threads in 100 words or less.`; + } + + protected getStudentResponses(): string { + return this.getDiscussionThreads().reduce( + (soFar, thread) => + `${soFar}${thread.post}${thread.replies.map((reply) => `${reply}`).join('')}`, + '' + ); + } + + private getDiscussionThreads(): Thread[] { + const threads = this.latestComponentStates + .filter((state) => state.studentData.componentStateIdReplyingTo == null) + .map((post) => ({ id: post.id, post: post.studentData.response, replies: [] })); + this.latestComponentStates + .filter((state) => state.studentData.componentStateIdReplyingTo != null) + .forEach((reply) => { + threads + .find((t) => t.id === reply.studentData.componentStateIdReplyingTo) + ?.replies.push(reply.studentData.response); + }); + return threads; + } +} diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts new file mode 100644 index 00000000000..7f9a14e6fc5 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.spec.ts @@ -0,0 +1,369 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OpenResponseAiSummaryComponent } from './open-response-ai-summary.component'; +import { MockComponent, MockProviders } from 'ng-mocks'; +import { AnnotationService } from '../../../services/annotationService'; +import { ConfigService } from '../../../services/configService'; +import { CRaterService } from '../../../services/cRaterService'; +import { ProjectService } from '../../../services/projectService'; +import { SummaryService } from '../../../components/summary/summaryService'; +import { TeacherDataService } from '../../../services/teacherDataService'; +import { LocalStorageService } from '../../../../../app/services/localStorageService'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { DataService } from '../../../../../app/services/data.service'; +import { MarkdownComponent, MarkdownService } from 'ngx-markdown'; +import { TeacherProjectService } from '../../../services/teacherProjectService'; +import { ChatService } from '../../../../../app/services/chat/chat.service'; +import { OpenAiChatService } from '../../../../../app/services/chat/openAiChat.service'; + +describe('OpenResponseAiSummaryComponent', () => { + let component: OpenResponseAiSummaryComponent; + let fixture: ComponentFixture; + let chatService: ChatService; + let localStorageService: LocalStorageService; + let dataService: TeacherDataService; + let projectService: ProjectService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OpenResponseAiSummaryComponent, MockComponent(MarkdownComponent)], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: DataService, useExisting: TeacherDataService }, + MockProviders( + AnnotationService, + ConfigService, + CRaterService, + LocalStorageService, + MarkdownService, + OpenAiChatService, + TeacherProjectService, + SummaryService, + TeacherDataService + ) + ] + }).compileComponents(); + + chatService = TestBed.inject(OpenAiChatService); + localStorageService = TestBed.inject(LocalStorageService); + dataService = TestBed.inject(TeacherDataService); + projectService = TestBed.inject(TeacherProjectService); + + spyOn(projectService, 'getComponent').and.returnValue({ + id: 'component1', + type: 'OpenResponse', + prompt: 'What is your opinion on climate change?' + } as any); + + fixture = TestBed.createComponent(OpenResponseAiSummaryComponent); + component = fixture.componentInstance; + component.nodeId = 'node1'; + component.componentId = 'component1'; + component.periodId = 1; + }); + + describe('ngOnInit', () => { + it('should set hasStudentResponses to false when no component states exist', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue([]); + component.ngOnInit(); + fixture.detectChanges(); + expect(component['hasStudentResponses']).toBe(false); + }); + + it('should set hasStudentResponses to true when component states exist', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + fixture.detectChanges(); + expect(component['hasStudentResponses']).toBe(true); + }); + + it('should load summary from localStorage if it exists', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const savedSummary = 'This is a saved summary'; + spyOn(localStorageService, 'getItem').and.returnValues(savedSummary, 1000); + fixture.detectChanges(); + expect(component['summary']).toBe(savedSummary); + }); + + it('should set newSummaryAvailable to true when responses are newer than summary', () => { + const componentStates = getComponentStates(); + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + const oldTimestamp = 1000; + spyOn(localStorageService, 'getItem').and.returnValues('Old summary', oldTimestamp); + fixture.detectChanges(); + expect(component['newSummaryAvailable']).toBe(true); + }); + + it('should set newSummaryAvailable to false when summary is newer than responses', () => { + const componentStates = getComponentStates(); + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + const futureTimestamp = Date.now() + 100000; + spyOn(localStorageService, 'getItem').and.returnValues('Recent summary', futureTimestamp); + component.ngOnInit(); + fixture.detectChanges(); + expect(component['newSummaryAvailable']).toBe(false); + }); + }); + + describe('getLatestComponentStates', () => { + it('should filter component states by period ID', () => { + const componentStates = getComponentStates(); + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + component.periodId = 1; + const result = component['getLatestComponentStates'](); + expect(result.every((state) => state.periodId === 1)).toBe(true); + }); + + it('should return all component states when periodId is -1', () => { + const componentStates = [ + ...getComponentStates(), + { + id: 4, + componentId: 'component1', + nodeId: 'node1', + periodId: 2, + runId: 1, + serverSaveTime: 4000, + studentData: { response: 'Response from period 2' }, + workgroupId: 4 + } + ]; + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + component.periodId = -1; + const result = component['getLatestComponentStates'](); + expect(result.length).toBe(4); + }); + + it('should return only the latest state per workgroup', () => { + const componentStates = [ + ...getComponentStates(), + { + id: 4, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 5000, + studentData: { response: 'Updated response from workgroup 1' }, + workgroupId: 1 + } + ]; + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(componentStates); + component.periodId = 1; + const result = component['getLatestComponentStates'](); + const workgroup1States = result.filter((state) => state.workgroupId === 1); + expect(workgroup1States.length).toBe(1); + expect(workgroup1States[0].serverSaveTime).toBe(5000); + }); + }); + + describe('generateSummary', () => { + beforeEach(() => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should call chatService with correct system prompt', async () => { + const sendMessageSpy = spyOn(chatService, 'sendMessage').and.returnValue( + Promise.resolve('Generated summary') + ); + await component['generateSummary'](); + const messages = sendMessageSpy.calls.mostRecent().args[0]; + expect(messages[0].role).toBe('system'); + expect(messages[0].content).toContain('What is your opinion on climate change?'); + }); + + it('should call chatService with student responses', async () => { + const sendMessageSpy = spyOn(chatService, 'sendMessage').and.returnValue( + Promise.resolve('Generated summary') + ); + await component['generateSummary'](); + const messages = sendMessageSpy.calls.mostRecent().args[0]; + expect(messages[1].role).toBe('user'); + expect(messages[1].content).toContain(''); + expect(messages[1].content).toContain('Climate change is real'); + }); + + it('should save summary to localStorage', async () => { + const generatedSummary = 'This is a generated summary'; + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); + const setItemSpy = spyOn(localStorageService, 'setItem'); + await component['generateSummary'](); + expect(setItemSpy).toHaveBeenCalledWith( + 'component-summary-1-node1-component1', + generatedSummary + ); + }); + + it('should save timestamp to localStorage', async () => { + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + const setItemSpy = spyOn(localStorageService, 'setItem'); + const beforeTime = new Date().getTime(); + await component['generateSummary'](); + const afterTime = new Date().getTime(); + const timestampCall = setItemSpy.calls.all().find((call) => call.args[0].includes('time')); + expect(timestampCall).toBeDefined(); + expect(timestampCall.args[1]).toBeGreaterThanOrEqual(beforeTime); + expect(timestampCall.args[1]).toBeLessThanOrEqual(afterTime); + }); + + it('should set generatingSummary to false after completion', async () => { + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + await component['generateSummary'](); + expect(component['generatingSummary']).toBe(false); + }); + + it('should set newSummaryAvailable to false after generation', async () => { + component['newSummaryAvailable'] = true; + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve('Generated summary')); + await component['generateSummary'](); + expect(component['newSummaryAvailable']).toBe(false); + }); + + it('should update summary property', async () => { + const generatedSummary = 'This is a generated summary'; + spyOn(chatService, 'sendMessage').and.returnValue(Promise.resolve(generatedSummary)); + await component['generateSummary'](); + expect(component['summary']).toBe(generatedSummary); + }); + }); + + describe('getStudentResponses', () => { + it('should format student responses with XML tags', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + const responses = component['getStudentResponses'](); + expect(responses).toContain('Climate change is real'); + expect(responses).toContain('We need to act now'); + expect(responses).toContain('Renewable energy is the future'); + }); + + it('should concatenate all responses', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + const responses = component['getStudentResponses'](); + const responseCount = (responses.match(//g) || []).length; + expect(responseCount).toBe(3); + }); + }); + + describe('template rendering', () => { + it('should display "No student responses" when hasStudentResponses is false', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue([]); + component.ngOnInit(); + fixture.detectChanges(); + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('No student responses'); + }); + + it('should display generate button when hasStudentResponses is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button'); + expect(button).toBeTruthy(); + expect(button.textContent).toContain('Generate Class Summary'); + }); + + it('should disable generate button when generatingSummary is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + component['generatingSummary'] = true; + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBe(true); + }); + + it('should display "New responses since last summary" when newSummaryAvailable is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const oldTimestamp = 1000; + spyOn(localStorageService, 'getItem') + .withArgs('component-summary-1-node1-component1') + .and.returnValue('Old summary') + .withArgs('component-summary-time-1-node1-component1') + .and.returnValue(oldTimestamp); + component.ngOnInit(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('New responses since last summary'); + }); + + it('should display spinner when generatingSummary is true', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + component.ngOnInit(); + component['generatingSummary'] = true; + fixture.detectChanges(); + const spinner = fixture.nativeElement.querySelector('mat-spinner'); + expect(spinner).toBeTruthy(); + }); + + it('should display summary when summary exists', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const savedSummary = 'This is a saved summary'; + spyOn(localStorageService, 'getItem') + .withArgs('component-summary-1-node1-component1') + .and.returnValue(savedSummary) + .withArgs('component-summary-time-1-node1-component1') + .and.returnValue(Date.now() + 100000); + component.ngOnInit(); + fixture.detectChanges(); + const markdown = fixture.nativeElement.querySelector('markdown'); + expect(markdown).toBeTruthy(); + }); + + it('should display response count when summary exists', () => { + spyOn(dataService, 'getComponentStatesByComponentId').and.returnValue(getComponentStates()); + const savedSummary = 'This is a saved summary'; + spyOn(localStorageService, 'getItem') + .withArgs('component-summary-1-node1-component1') + .and.returnValue(savedSummary) + .withArgs('component-summary-time-1-node1-component1') + .and.returnValue(Date.now() + 100000); + component.ngOnInit(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('3 responses'); + }); + }); +}); + +function getComponentStates(): any[] { + return [ + { + id: 1, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 1000, + studentData: { + response: 'Climate change is real' + }, + workgroupId: 1 + }, + { + id: 2, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 2000, + studentData: { + response: 'We need to act now' + }, + workgroupId: 2 + }, + { + id: 3, + componentId: 'component1', + nodeId: 'node1', + periodId: 1, + runId: 1, + serverSaveTime: 3000, + studentData: { + response: 'Renewable energy is the future' + }, + workgroupId: 3 + } + ]; +} diff --git a/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts new file mode 100644 index 00000000000..42a914263a4 --- /dev/null +++ b/src/assets/wise5/directives/teacher-summary-display/open-response-ai-summary/open-response-ai-summary.component.ts @@ -0,0 +1,43 @@ +import { DatePipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MarkdownComponent } from 'ngx-markdown'; +import { AiSummaryComponent } from '../ai-summary/ai-summary.component'; + +/** + * Uses an LLM to summarize students' responses to open response questions. + */ +@Component({ + imports: [DatePipe, MarkdownComponent, MatButton, MatIcon, MatProgressSpinner], + selector: 'open-response-ai-summary', + templateUrl: '../ai-summary/ai-summary.component.html' +}) +export class OpenResponseAiSummaryComponent extends AiSummaryComponent { + protected getSystemPrompt(prompt: string): string { + return `You are a teacher who is summarizing student responses to the following question: "${prompt}". + Each student response is in the format: Response. + In the same language as the question, provide a summary of the responses in 100 words or less.`; + } + + protected getStudentResponses(): string { + return this.getLatestComponentStates().reduce( + (soFar, state) => `${soFar}${state.studentData.response}`, + '' + ); + } + protected getLatestComponentStates(): any[] { + return this.dataService + .getComponentStatesByComponentId(this.componentId) + .filter((state) => state.periodId === this.periodId || this.periodId === -1) + .sort((a, b) => a.serverSaveTime - b.serverSaveTime) + .reduceRight( + (soFar, currentState) => + soFar.find((state) => state.workgroupId === currentState.workgroupId) + ? soFar + : soFar.concat(currentState), + [] + ); + } +} diff --git a/src/messages.xlf b/src/messages.xlf index cba0709afeb..a63098a0882 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -22554,6 +22554,66 @@ If this problem continues, let your teacher know and move on to the next activit 401 + + Generate Class Summary + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 5,7 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 5,7 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 5,7 + + + + *New responses since last summary + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 12,16 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 12,16 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 12,16 + + + + Summary generated from responses + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 18,22 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 18,22 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 18,22 + + + + No student responses + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 23,25 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 23,25 + + + src/assets/wise5/directives/teacher-summary-display/ai-summary/ai-summary.component.html + 23,25 + + Class Discussion