diff --git a/services/cms/package.json b/services/cms/package.json index c4e6bbfaca6..26bb0d6e551 100644 --- a/services/cms/package.json +++ b/services/cms/package.json @@ -55,6 +55,8 @@ "axios": "^1.13.6", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "diff": "^8.0.3", + "dompurify": "^3.3.2", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", "i18next": "^25.8.13", diff --git a/services/cms/pnpm-lock.yaml b/services/cms/pnpm-lock.yaml index 226abd6b3f3..c51b4e6ee09 100644 --- a/services/cms/pnpm-lock.yaml +++ b/services/cms/pnpm-lock.yaml @@ -143,6 +143,12 @@ importers: date-fns-tz: specifier: ^3.2.0 version: 3.2.0(date-fns@4.1.0) + diff: + specifier: ^8.0.3 + version: 8.0.3 + dompurify: + specifier: ^3.3.2 + version: 3.3.2 express: specifier: ^5.2.1 version: 5.2.1 @@ -157,10 +163,10 @@ importers: version: 11.1.4 jotai: specifier: ^2.18.0 - version: 2.18.0(@babel/core@7.28.4)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1) + version: 2.18.0(@babel/core@7.25.7)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1) jotai-family: specifier: ^1.0.1 - version: 1.0.1(jotai@2.18.0(@babel/core@7.28.4)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1)) + version: 1.0.1(jotai@2.18.0(@babel/core@7.25.7)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1)) katex: specifier: ^0.16.33 version: 0.16.33 @@ -172,7 +178,7 @@ importers: version: 0.55.1 next: specifier: ^16.1.6 - version: 16.1.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3) + version: 16.1.6(@babel/core@7.25.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3) react: specifier: ^18.3.1 version: 18.3.1 @@ -193,13 +199,13 @@ importers: version: 2.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-i18next: specifier: ^16.5.4 - version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(typescript@5.9.3) react-popper: specifier: ^2.3.0 version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-spring: specifier: ^10.0.3 - version: 10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0))(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react-konva@19.0.10(@types/react@18.3.28)(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react-zdog@1.2.2)(react@18.3.1)(three@0.180.0)(zdog@1.1.3) + version: 10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0))(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react-konva@19.0.10(@types/react@18.3.28)(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react-zdog@1.2.2)(react@18.3.1)(three@0.180.0)(zdog@1.1.3) svgo: specifier: ^4.0.0 version: 4.0.0 @@ -278,7 +284,7 @@ importers: version: 18.0.0(stylelint@17.4.0(typescript@5.9.3)) ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@25.3.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.3.3)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.25.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.25.7))(jest-util@30.2.0)(jest@30.2.0(@types/node@25.3.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.3.3)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@25.3.3)(typescript@5.9.3) @@ -4006,9 +4012,6 @@ packages: '@types/mysql@2.15.26': resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} - '@types/node@25.3.0': - resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} - '@types/node@25.3.3': resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} @@ -4087,8 +4090,8 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/webxr@0.5.24': - resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/webxr@0.5.22': + resolution: {integrity: sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==} '@types/wordpress__block-editor@15.0.5': resolution: {integrity: sha512-nQNBDiVISlJWelHG+V7ikSaDFHWeKx60IyCQIG2qiePaeqjHuOaWWVJl0H1QWyOMUnmDuro2Y+GNsaNvjufuXA==} @@ -5932,10 +5935,6 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@4.0.4: - resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} - engines: {node: '>=0.3.1'} - diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -5969,6 +5968,10 @@ packages: dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -6348,8 +6351,8 @@ packages: resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - exponential-backoff@3.1.3: - resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + exponential-backoff@3.1.2: + resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==} express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} @@ -10633,8 +10636,8 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + yaml@2.8.0: + resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} hasBin: true @@ -10684,8 +10687,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zustand@5.0.9: - resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + zustand@5.0.4: + resolution: {integrity: sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -14827,9 +14830,9 @@ snapshots: '@react-native/assets-registry@0.81.4': {} - '@react-native/codegen@0.81.4(@babel/core@7.28.4)': + '@react-native/codegen@0.81.4(@babel/core@7.25.7)': dependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.25.7 '@babel/parser': 7.29.0 glob: 7.2.3 hermes-parser: 0.29.1 @@ -14877,12 +14880,12 @@ snapshots: '@react-native/normalize-colors@0.81.4': {} - '@react-native/virtualized-lists@0.81.4(@types/react@18.3.28)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)': + '@react-native/virtualized-lists@0.81.4(@types/react@18.3.28)(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 18.3.1 - react-native: 0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1) + react-native: 0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 @@ -14922,14 +14925,14 @@ snapshots: react: 18.3.1 react-konva: 19.0.10(@types/react@18.3.28)(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-spring/native@10.0.3(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)': + '@react-spring/native@10.0.3(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)': dependencies: '@react-spring/animated': 10.0.3(react@18.3.1) '@react-spring/core': 10.0.3(react@18.3.1) '@react-spring/shared': 10.0.3(react@18.3.1) '@react-spring/types': 10.0.3 react: 18.3.1 - react-native: 0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1) + react-native: 0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1) '@react-spring/rafz@10.0.3': {} @@ -14947,13 +14950,13 @@ snapshots: '@react-spring/types': 9.7.5 react: 18.3.1 - '@react-spring/three@10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0))(react@18.3.1)(three@0.180.0)': + '@react-spring/three@10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0))(react@18.3.1)(three@0.180.0)': dependencies: '@react-spring/animated': 10.0.3(react@18.3.1) '@react-spring/core': 10.0.3(react@18.3.1) '@react-spring/shared': 10.0.3(react@18.3.1) '@react-spring/types': 10.0.3 - '@react-three/fiber': 9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0) + '@react-three/fiber': 9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0) react: 18.3.1 three: 0.180.0 @@ -15248,11 +15251,11 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0)': + '@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0)': dependencies: '@babel/runtime': 7.28.6 '@types/react-reconciler': 0.32.3(@types/react@18.3.28) - '@types/webxr': 0.5.24 + '@types/webxr': 0.5.22 base64-js: 1.5.1 buffer: 6.0.3 its-fine: 2.0.0(@types/react@18.3.28)(react@18.3.1) @@ -15263,10 +15266,10 @@ snapshots: suspend-react: 0.1.3(react@18.3.1) three: 0.180.0 use-sync-external-store: 1.6.0(react@18.3.1) - zustand: 5.0.9(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + zustand: 5.0.4(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) optionalDependencies: react-dom: 18.3.1(react@18.3.1) - react-native: 0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1) + react-native: 0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1) transitivePeerDependencies: - '@types/react' - immer @@ -15792,10 +15795,6 @@ snapshots: dependencies: '@types/node': 25.3.3 - '@types/node@25.3.0': - dependencies: - undici-types: 7.18.2 - '@types/node@25.3.3': dependencies: undici-types: 7.18.2 @@ -15877,7 +15876,7 @@ snapshots: '@types/trusted-types@2.0.7': optional: true - '@types/webxr@0.5.24': {} + '@types/webxr@0.5.22': {} '@types/wordpress__block-editor@15.0.5(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -16318,7 +16317,7 @@ snapshots: clsx: 2.1.1 colord: 2.9.3 deepmerge: 4.3.1 - diff: 4.0.4 + diff: 8.0.3 fast-deep-equal: 3.1.3 memize: 2.1.1 parsel-js: 1.2.2 @@ -17516,18 +17515,19 @@ snapshots: transitivePeerDependencies: - supports-color - babel-jest@29.7.0(@babel/core@7.28.4): + babel-jest@30.2.0(@babel/core@7.25.7): dependencies: - '@babel/core': 7.28.4 - '@jest/transform': 29.7.0 + '@babel/core': 7.25.7 + '@jest/transform': 30.2.0 '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.28.4) + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.2.0(@babel/core@7.25.7) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 transitivePeerDependencies: - supports-color + optional: true babel-jest@30.2.0(@babel/core@7.28.4): dependencies: @@ -17682,11 +17682,12 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.25.7) - babel-preset-jest@29.6.3(@babel/core@7.28.4): + babel-preset-jest@30.2.0(@babel/core@7.25.7): dependencies: - '@babel/core': 7.28.4 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) + '@babel/core': 7.25.7 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.25.7) + optional: true babel-preset-jest@30.2.0(@babel/core@7.28.4): dependencies: @@ -17967,7 +17968,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -17999,7 +18000,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -18505,8 +18506,6 @@ snapshots: diff-sequences@29.6.3: {} - diff@4.0.4: {} - diff@8.0.3: {} dir-glob@3.0.1: @@ -18541,6 +18540,10 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -19067,7 +19070,7 @@ snapshots: jest-mock: 30.2.0 jest-util: 30.2.0 - exponential-backoff@3.1.3: {} + exponential-backoff@3.1.2: {} express@4.22.1: dependencies: @@ -20764,13 +20767,13 @@ snapshots: '@hapi/topo': 6.0.2 '@standard-schema/spec': 1.1.0 - jotai-family@1.0.1(jotai@2.18.0(@babel/core@7.28.4)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1)): + jotai-family@1.0.1(jotai@2.18.0(@babel/core@7.25.7)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1)): dependencies: - jotai: 2.18.0(@babel/core@7.28.4)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1) + jotai: 2.18.0(@babel/core@7.25.7)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1) - jotai@2.18.0(@babel/core@7.28.4)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1): + jotai@2.18.0(@babel/core@7.25.7)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1): optionalDependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.25.7 '@babel/template': 7.28.6 '@types/react': 18.3.28 react: 18.3.1 @@ -21228,7 +21231,7 @@ snapshots: metro-cache@0.83.3: dependencies: - exponential-backoff: 3.1.3 + exponential-backoff: 3.1.2 flow-enums-runtime: 0.0.6 https-proxy-agent: 7.0.6 metro-core: 0.83.3 @@ -21244,7 +21247,7 @@ snapshots: metro-cache: 0.83.3 metro-core: 0.83.3 metro-runtime: 0.83.3 - yaml: 2.8.2 + yaml: 2.8.0 transitivePeerDependencies: - bufferutil - supports-color @@ -21504,7 +21507,7 @@ snapshots: netmask@2.0.2: {} - next@16.1.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3): + next@16.1.6(@babel/core@7.25.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.97.3): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 @@ -21513,7 +21516,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.6(@babel/core@7.28.4)(babel-plugin-macros@3.1.0)(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.25.7)(babel-plugin-macros@3.1.0)(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -22430,7 +22433,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-i18next@16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(typescript@5.9.3): + react-i18next@16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(typescript@5.9.3): dependencies: '@babel/runtime': 7.28.6 html-parse-stringify: 3.0.1 @@ -22439,7 +22442,7 @@ snapshots: use-sync-external-store: 1.6.0(react@18.3.1) optionalDependencies: react-dom: 18.3.1(react@18.3.1) - react-native: 0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1) + react-native: 0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1) typescript: 5.9.3 react-is@16.13.1: {} @@ -22458,20 +22461,20 @@ snapshots: transitivePeerDependencies: - '@types/react' - react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1): + react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1): dependencies: '@jest/create-cache-key-function': 29.7.0 '@react-native/assets-registry': 0.81.4 - '@react-native/codegen': 0.81.4(@babel/core@7.28.4) + '@react-native/codegen': 0.81.4(@babel/core@7.25.7) '@react-native/community-cli-plugin': 0.81.4 '@react-native/gradle-plugin': 0.81.4 '@react-native/js-polyfills': 0.81.4 '@react-native/normalize-colors': 0.81.4 - '@react-native/virtualized-lists': 0.81.4(@types/react@18.3.28)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) + '@react-native/virtualized-lists': 0.81.4(@types/react@18.3.28)(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 - babel-jest: 29.7.0(@babel/core@7.28.4) + babel-jest: 29.7.0(@babel/core@7.25.7) babel-plugin-syntax-hermes-parser: 0.29.1 base64-js: 1.5.1 commander: 12.1.0 @@ -22546,12 +22549,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - react-spring@10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0))(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react-konva@19.0.10(@types/react@18.3.28)(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react-zdog@1.2.2)(react@18.3.1)(three@0.180.0)(zdog@1.1.3): + react-spring@10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0))(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react-konva@19.0.10(@types/react@18.3.28)(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react-zdog@1.2.2)(react@18.3.1)(three@0.180.0)(zdog@1.1.3): dependencies: '@react-spring/core': 10.0.3(react@18.3.1) '@react-spring/konva': 10.0.3(konva@10.0.2)(react-konva@19.0.10(@types/react@18.3.28)(konva@10.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - '@react-spring/native': 10.0.3(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) - '@react-spring/three': 10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0))(react@18.3.1)(three@0.180.0) + '@react-spring/native': 10.0.3(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) + '@react-spring/three': 10.0.3(@react-three/fiber@9.3.0(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.81.4(@babel/core@7.25.7)(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)(three@0.180.0))(react@18.3.1)(three@0.180.0) '@react-spring/web': 10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-spring/zdog': 10.0.3(react-dom@18.3.1(react@18.3.1))(react-zdog@1.2.2)(react@18.3.1)(zdog@1.1.3) react: 18.3.1 @@ -23385,12 +23388,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.4)(babel-plugin-macros@3.1.0)(react@18.3.1): + styled-jsx@5.1.6(@babel/core@7.25.7)(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 optionalDependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.25.7 babel-plugin-macros: 3.1.0 stylehacks@6.1.1(postcss@8.5.6): @@ -23749,7 +23752,7 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.6(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@25.3.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.3.3)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.25.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.25.7))(jest-util@30.2.0)(jest@30.2.0(@types/node@25.3.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.3.3)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -23763,10 +23766,10 @@ snapshots: typescript: 5.9.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.25.7 '@jest/transform': 30.2.0 '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.28.4) + babel-jest: 30.2.0(@babel/core@7.25.7) jest-util: 30.2.0 ts-node@10.9.2(@types/node@25.3.3)(typescript@5.9.3): @@ -24385,7 +24388,7 @@ snapshots: yaml@1.10.2: {} - yaml@2.8.2: {} + yaml@2.8.0: {} yargs-parser@15.0.3: dependencies: @@ -24441,7 +24444,7 @@ snapshots: zod@3.25.76: {} - zustand@5.0.9(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): + zustand@5.0.4(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.28 immer: 11.1.4 diff --git a/services/cms/src/components/editors/GutenbergEditor.tsx b/services/cms/src/components/editors/GutenbergEditor.tsx index c1a8de88460..70bca34fc82 100644 --- a/services/cms/src/components/editors/GutenbergEditor.tsx +++ b/services/cms/src/components/editors/GutenbergEditor.tsx @@ -52,6 +52,7 @@ import { useTranslation } from "react-i18next" import useDisableBrowserDefaultDragFileBehavior from "../../hooks/useDisableBrowserDefaultDragFileBehavior" import useSidebarStartingYCoodrinate from "../../hooks/useSidebarStartingYCoodrinate" import { MediaUploadProps } from "../../services/backend/media/mediaUpload" +import { registerEditorAiAbilities } from "../../utils/Gutenberg/ai/abilities" import { modifyEmbedBlockAttributes, modifyImageBlockAttributes, @@ -61,6 +62,7 @@ import { modifyGutenbergCategories } from "../../utils/Gutenberg/modifyGutenberg import { registerBlockVariations } from "../../utils/Gutenberg/registerBlockVariations" import runMigrationsAndValidations from "../../utils/Gutenberg/runMigrationsAndValidations" import withMentimeterInspector from "../../utils/Gutenberg/withMentimeterInspector" +import withParagraphAiToolbarAction from "../../utils/Gutenberg/withParagraphAiToolbarAction" import CommonKeyboardShortcuts from "../CommonKeyboardShortcuts" import SelectField from "@/shared-module/common/components/InputFields/SelectField" @@ -203,6 +205,14 @@ const GutenbergEditor: React.FC> = } }, []) + useEffect(() => { + registerEditorAiAbilities() + addFilter("editor.BlockEdit", "moocfi/cms/paragraphAiToolbar", withParagraphAiToolbarAction) + return () => { + removeFilter("editor.BlockEdit", "moocfi/cms/paragraphAiToolbar") + } + }, []) + // This **should** be the last useEffect as it supposes that Gutenberg is fully set up // Runs migrations and validations for the blocks useEffect(() => { diff --git a/services/cms/src/services/backend/ai-suggestions.ts b/services/cms/src/services/backend/ai-suggestions.ts new file mode 100644 index 00000000000..9007aa1fc9d --- /dev/null +++ b/services/cms/src/services/backend/ai-suggestions.ts @@ -0,0 +1,19 @@ +import { cmsClient } from "./cmsClient" + +import type { + ParagraphSuggestionRequest, + ParagraphSuggestionResponse, +} from "@/shared-module/common/bindings" +import { isParagraphSuggestionResponse } from "@/shared-module/common/bindings.guard" +import { validateResponse } from "@/shared-module/common/utils/fetching" + +/** + * Sends a ParagraphSuggestionRequest to `/ai-suggestions/paragraph` and returns a validated ParagraphSuggestionResponse. + * Uses `validateResponse` with `isParagraphSuggestionResponse`; throws on request or validation failures. + */ +export async function requestParagraphSuggestions( + payload: ParagraphSuggestionRequest, +): Promise { + const response = await cmsClient.post("/ai-suggestions/paragraph", payload) + return validateResponse(response, isParagraphSuggestionResponse) +} diff --git a/services/cms/src/utils/Gutenberg/ai/abilities.ts b/services/cms/src/utils/Gutenberg/ai/abilities.ts new file mode 100644 index 00000000000..5a328daae6c --- /dev/null +++ b/services/cms/src/utils/Gutenberg/ai/abilities.ts @@ -0,0 +1,145 @@ +import { AI_ACTIONS, AI_TONE_SUBMENU, AI_TRANSLATE_SUBMENU } from "./menu" +import { registerAbility } from "./registry" +import type { AbilityDefinition } from "./types" + +import { requestParagraphSuggestions } from "@/services/backend/ai-suggestions" +import type { + ParagraphSuggestionContext, + ParagraphSuggestionRequest, +} from "@/shared-module/common/bindings" + +export interface ParagraphAbilityInputMeta { + tone?: string + language?: string + settingType?: string + context?: ParagraphSuggestionContext | null +} + +export interface ParagraphAbilityInput { + text: string + isHtml?: boolean + meta?: ParagraphAbilityInputMeta +} + +const BASE_INPUT_SCHEMA = { + type: "object", + properties: { + text: { type: "string" }, + isHtml: { type: "boolean" }, + meta: { type: "object" }, + }, + required: ["text"], +} + +const BASE_OUTPUT_SCHEMA = { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], +} + +const buildParagraphSuggestionMeta = ( + meta?: ParagraphAbilityInputMeta, +): ParagraphSuggestionRequest["meta"] => { + if (!meta) { + return null + } + + return { + tone: meta.tone ?? null, + language: meta.language ?? null, + setting_type: meta.settingType ?? null, + } +} + +export const buildParagraphSuggestionRequest = ( + action: string, + input: ParagraphAbilityInput, +): ParagraphSuggestionRequest => ({ + action, + content: input.text, + is_html: input.isHtml ?? false, + meta: buildParagraphSuggestionMeta(input.meta), + context: input.meta?.context ?? null, +}) + +const fixSpellingAbility: AbilityDefinition< + ParagraphAbilityInput, + { text: string; suggestions: string[] } +> = { + name: "moocfi/fix-spelling", + label: "Fix spelling", + description: "Fix spelling and grammar in the selected text", + category: "ai", + input_schema: BASE_INPUT_SCHEMA, + output_schema: BASE_OUTPUT_SCHEMA, + callback: async (input) => { + const payload = buildParagraphSuggestionRequest("moocfi/fix-spelling", input) + const response = await requestParagraphSuggestions(payload) + + const suggestions = response.suggestions ?? [] + + if (suggestions.length === 0) { + throw new Error("No AI suggestions were returned for fix spelling") + } + + return { + text: suggestions[0] ?? input.text, + suggestions, + } + }, +} + +function createPlaceholderAbility( + abilityName: string, + label: string, + description: string, +): AbilityDefinition { + return { + name: abilityName, + label, + description, + category: "ai", + input_schema: BASE_INPUT_SCHEMA, + output_schema: BASE_OUTPUT_SCHEMA, + callback: async (input) => { + const payload = buildParagraphSuggestionRequest(abilityName, input) + const response = await requestParagraphSuggestions(payload) + + const suggestions = response.suggestions ?? [] + + if (suggestions.length === 0) { + throw new Error(`No AI suggestions were returned for ability ${abilityName}`) + } + + return { + text: suggestions[0] ?? input.text, + suggestions, + } + }, + } +} + +const allPlaceholderAbilities: AbilityDefinition< + ParagraphAbilityInput, + { text: string; suggestions: string[] } +>[] = [ + ...AI_ACTIONS.filter((action) => action.abilityName !== "moocfi/fix-spelling").map((action) => + createPlaceholderAbility(action.abilityName, action.id, `Placeholder for ${action.id}`), + ), + ...AI_TONE_SUBMENU.actions.map((action) => + createPlaceholderAbility(action.abilityName, action.id, `Placeholder for ${action.id}`), + ), + ...AI_TRANSLATE_SUBMENU.actions.map((action) => + createPlaceholderAbility(action.abilityName, action.id, `Placeholder for ${action.id}`), + ), +] + +/** Registers all editor AI abilities (call once when editor boots). */ +export function registerEditorAiAbilities(): void { + registerAbility(fixSpellingAbility) + for (const ability of allPlaceholderAbilities) { + registerAbility(ability) + } +} + +export { getAbility, executeAbility, registerAbilityCategory } from "./registry" diff --git a/services/cms/src/utils/Gutenberg/ai/menu.ts b/services/cms/src/utils/Gutenberg/ai/menu.ts new file mode 100644 index 00000000000..2b9fcd64dee --- /dev/null +++ b/services/cms/src/utils/Gutenberg/ai/menu.ts @@ -0,0 +1,292 @@ +export type AiActionGroupId = + | "generate" + | "improve" + | "structure" + | "learning-support" + | "summaries" + +export type AiSubmenuId = "tone" | "translate" + +export interface AiActionMeta { + tone?: string + language?: string + settingType?: "audience" | "reading-level" | "tone-preference" +} + +export interface AiActionDefinition { + id: string + abilityName: string + labelKey: string + group: AiActionGroupId | AiSubmenuId + meta?: AiActionMeta +} + +export interface AiMenuGroup { + id: AiActionGroupId + labelKey: string + actions: AiActionDefinition[] +} + +export interface AiSubmenuGroup { + id: AiSubmenuId + labelKey: string + actions: AiActionDefinition[] +} + +export const AI_ACTIONS: AiActionDefinition[] = [ + { + id: "generate-draft-from-notes", + abilityName: "moocfi/ai/generate-draft-from-notes", + labelKey: "ai-generate-draft-from-notes", + group: "generate", + }, + { + id: "generate-continue-paragraph", + abilityName: "moocfi/ai/generate-continue-paragraph", + labelKey: "ai-generate-continue-paragraph", + group: "generate", + }, + { + id: "generate-add-example", + abilityName: "moocfi/ai/generate-add-example", + labelKey: "ai-generate-add-example", + group: "generate", + }, + { + id: "generate-add-counterpoint", + abilityName: "moocfi/ai/generate-add-counterpoint", + labelKey: "ai-generate-add-counterpoint", + group: "generate", + }, + { + id: "generate-add-concluding-sentence", + abilityName: "moocfi/ai/generate-add-concluding-sentence", + labelKey: "ai-generate-add-concluding-sentence", + group: "generate", + }, + { + id: "improve-fix-spelling-grammar", + abilityName: "moocfi/fix-spelling", + labelKey: "ai-improve-fix-spelling-grammar", + group: "improve", + }, + { + id: "improve-clarity", + abilityName: "moocfi/ai/improve-clarity", + labelKey: "ai-improve-clarity", + group: "improve", + }, + { + id: "improve-flow", + abilityName: "moocfi/ai/improve-flow", + labelKey: "ai-improve-flow", + group: "improve", + }, + { + id: "improve-concise", + abilityName: "moocfi/ai/improve-concise", + labelKey: "ai-improve-concise", + group: "improve", + }, + { + id: "improve-expand-detail", + abilityName: "moocfi/ai/improve-expand-detail", + labelKey: "ai-improve-expand-detail", + group: "improve", + }, + { + id: "improve-academic-style", + abilityName: "moocfi/ai/improve-academic-style", + labelKey: "ai-improve-academic-style", + group: "improve", + }, + { + id: "structure-create-topic-sentence", + abilityName: "moocfi/ai/structure-create-topic-sentence", + labelKey: "ai-structure-create-topic-sentence", + group: "structure", + }, + { + id: "structure-reorder-sentences", + abilityName: "moocfi/ai/structure-reorder-sentences", + labelKey: "ai-structure-reorder-sentences", + group: "structure", + }, + { + id: "structure-split-into-paragraphs", + abilityName: "moocfi/ai/structure-split-into-paragraphs", + labelKey: "ai-structure-split-into-paragraphs", + group: "structure", + }, + { + id: "structure-combine-into-one", + abilityName: "moocfi/ai/structure-combine-into-one", + labelKey: "ai-structure-combine-into-one", + group: "structure", + }, + { + id: "structure-to-bullets", + abilityName: "moocfi/ai/structure-to-bullets", + labelKey: "ai-structure-to-bullets", + group: "structure", + }, + { + id: "structure-from-bullets", + abilityName: "moocfi/ai/structure-from-bullets", + labelKey: "ai-structure-from-bullets", + group: "structure", + }, + { + id: "learning-simplify-beginners", + abilityName: "moocfi/ai/learning-simplify-beginners", + labelKey: "ai-learning-simplify-beginners", + group: "learning-support", + }, + { + id: "learning-add-definitions", + abilityName: "moocfi/ai/learning-add-definitions", + labelKey: "ai-learning-add-definitions", + group: "learning-support", + }, + { + id: "learning-add-analogy", + abilityName: "moocfi/ai/learning-add-analogy", + labelKey: "ai-learning-add-analogy", + group: "learning-support", + }, + { + id: "learning-add-practice-question", + abilityName: "moocfi/ai/learning-add-practice-question", + labelKey: "ai-learning-add-practice-question", + group: "learning-support", + }, + { + id: "learning-add-check-understanding", + abilityName: "moocfi/ai/learning-add-check-understanding", + labelKey: "ai-learning-add-check-understanding", + group: "learning-support", + }, + { + id: "summaries-one-sentence", + abilityName: "moocfi/ai/summaries-one-sentence", + labelKey: "ai-summaries-one-sentence", + group: "summaries", + }, + { + id: "summaries-two-three-sentences", + abilityName: "moocfi/ai/summaries-two-three-sentences", + labelKey: "ai-summaries-two-three-sentences", + group: "summaries", + }, + { + id: "summaries-key-takeaway", + abilityName: "moocfi/ai/summaries-key-takeaway", + labelKey: "ai-summaries-key-takeaway", + group: "summaries", + }, +] + +export const AI_GROUPS: AiMenuGroup[] = [ + { + id: "generate", + labelKey: "ai-group-generate", + actions: AI_ACTIONS.filter((action) => action.group === "generate"), + }, + { + id: "improve", + labelKey: "ai-group-improve", + actions: AI_ACTIONS.filter((action) => action.group === "improve"), + }, + { + id: "structure", + labelKey: "ai-group-structure", + actions: AI_ACTIONS.filter((action) => action.group === "structure"), + }, + { + id: "learning-support", + labelKey: "ai-group-learning-support", + actions: AI_ACTIONS.filter((action) => action.group === "learning-support"), + }, + { + id: "summaries", + labelKey: "ai-group-summaries", + actions: AI_ACTIONS.filter((action) => action.group === "summaries"), + }, +] + +export const AI_TONE_SUBMENU: AiSubmenuGroup = { + id: "tone", + labelKey: "ai-submenu-tone-voice", + actions: [ + { + id: "tone-academic-formal", + abilityName: "moocfi/ai/tone-academic-formal", + labelKey: "ai-tone-academic-formal", + group: "tone", + meta: { tone: "academic-formal" }, + }, + { + id: "tone-friendly-conversational", + abilityName: "moocfi/ai/tone-friendly-conversational", + labelKey: "ai-tone-friendly-conversational", + group: "tone", + meta: { tone: "friendly-conversational" }, + }, + { + id: "tone-encouraging-supportive", + abilityName: "moocfi/ai/tone-encouraging-supportive", + labelKey: "ai-tone-encouraging-supportive", + group: "tone", + meta: { tone: "encouraging-supportive" }, + }, + { + id: "tone-neutral-objective", + abilityName: "moocfi/ai/tone-neutral-objective", + labelKey: "ai-tone-neutral-objective", + group: "tone", + meta: { tone: "neutral-objective" }, + }, + { + id: "tone-confident", + abilityName: "moocfi/ai/tone-confident", + labelKey: "ai-tone-confident", + group: "tone", + meta: { tone: "confident" }, + }, + { + id: "tone-serious", + abilityName: "moocfi/ai/tone-serious", + labelKey: "ai-tone-serious", + group: "tone", + meta: { tone: "serious" }, + }, + ], +} + +export const AI_TRANSLATE_SUBMENU: AiSubmenuGroup = { + id: "translate", + labelKey: "ai-submenu-translate", + actions: [ + { + id: "translate-english", + abilityName: "moocfi/ai/translate-english", + labelKey: "ai-translate-english", + group: "translate", + meta: { language: "en" }, + }, + { + id: "translate-finnish", + abilityName: "moocfi/ai/translate-finnish", + labelKey: "ai-translate-finnish", + group: "translate", + meta: { language: "fi" }, + }, + { + id: "translate-swedish", + abilityName: "moocfi/ai/translate-swedish", + labelKey: "ai-translate-swedish", + group: "translate", + meta: { language: "sv" }, + }, + ], +} diff --git a/services/cms/src/utils/Gutenberg/ai/registry.ts b/services/cms/src/utils/Gutenberg/ai/registry.ts new file mode 100644 index 00000000000..4601a6c6576 --- /dev/null +++ b/services/cms/src/utils/Gutenberg/ai/registry.ts @@ -0,0 +1,36 @@ +import type { AbilityCategory, AbilityDefinition } from "./types" + +/** Reserved for future ability grouping: `registerAbilityCategory` stores `AbilityCategory` metadata here. */ +const categories = new Map() +const abilities = new Map>() + +/** Registers an ability category (e.g. "ai" for LLM actions). */ +export function registerAbilityCategory(name: string, category: AbilityCategory): void { + categories.set(name, category) +} + +/** Registers a single ability with name, schemas, and callback. */ +export function registerAbility(definition: AbilityDefinition): void { + abilities.set(definition.name, definition as AbilityDefinition) +} + +/** Returns the ability definition for a given name, or undefined. */ +export function getAbility(name: string): AbilityDefinition | undefined { + return abilities.get(name) +} + +/** Executes an ability by name with the given input; validates output has required fields. */ +export async function executeAbility( + name: string, + input: unknown, +): Promise { + const ability = abilities.get(name) + if (!ability) { + throw new Error(`Unknown ability: ${name}`) + } + const result = (await ability.callback(input)) as O + if (typeof result !== "object" || result === null || typeof (result as O).text !== "string") { + throw new Error(`Ability ${name} did not return { text: string }`) + } + return result +} diff --git a/services/cms/src/utils/Gutenberg/ai/types.ts b/services/cms/src/utils/Gutenberg/ai/types.ts new file mode 100644 index 00000000000..63d05e3fe22 --- /dev/null +++ b/services/cms/src/utils/Gutenberg/ai/types.ts @@ -0,0 +1,20 @@ +export interface JSONSchemaObject { + type: string + properties?: Record + required?: string[] +} + +export interface AbilityDefinition { + name: string + label: string + description: string + category: string + input_schema: JSONSchemaObject + output_schema: JSONSchemaObject + callback: (input: I) => Promise +} + +export interface AbilityCategory { + label: string + description?: string +} diff --git a/services/cms/src/utils/Gutenberg/paragraphAiSource.ts b/services/cms/src/utils/Gutenberg/paragraphAiSource.ts new file mode 100644 index 00000000000..9a28a316f4a --- /dev/null +++ b/services/cms/src/utils/Gutenberg/paragraphAiSource.ts @@ -0,0 +1,49 @@ +export interface ParagraphAiSource { + originalHtml: string + originalText: string + requestContent: string + requestIsHtml: boolean +} + +const HTML_TAG_PATTERN = /<\/?[a-z][^>]*>/i + +/** Extracts readable text from a paragraph HTML fragment. */ +export const extractPlainTextFromHtml = (html: string): string => { + if (!html) { + return "" + } + + if (typeof window !== "undefined" && typeof document !== "undefined") { + const element = document.createElement("div") + element.innerHTML = html + return element.textContent || element.innerText || "" + } + + return html.replace(/<[^>]+>/g, "") +} + +/** + * Returns whether a suggestion changes the saved paragraph HTML, including + * markup-only edits that leave the visible text unchanged. + */ +export const hasMeaningfulParagraphSuggestionChange = ( + originalHtml: string, + suggestion: string, +): boolean => { + const normalizedOriginalHtml = typeof originalHtml === "string" ? originalHtml.trim() : "" + const normalizedSuggestion = typeof suggestion === "string" ? suggestion.trim() : "" + + return normalizedSuggestion.length > 0 && normalizedSuggestion !== normalizedOriginalHtml +} + +/** Returns the content sent to the AI request and the plain-text preview text. */ +export const createParagraphAiSource = (html: string): ParagraphAiSource => { + const originalHtml = typeof html === "string" ? html : "" + + return { + originalHtml, + originalText: extractPlainTextFromHtml(originalHtml), + requestContent: originalHtml, + requestIsHtml: HTML_TAG_PATTERN.test(originalHtml), + } +} diff --git a/services/cms/src/utils/Gutenberg/paragraphHtmlSanitizer.ts b/services/cms/src/utils/Gutenberg/paragraphHtmlSanitizer.ts new file mode 100644 index 00000000000..c214e9f68d9 --- /dev/null +++ b/services/cms/src/utils/Gutenberg/paragraphHtmlSanitizer.ts @@ -0,0 +1,617 @@ +import DOMPurify from "dompurify" + +const INLINE_TAGS = new Set([ + "A", + "STRONG", + "B", + "EM", + "I", + "CODE", + "KBD", + "S", + "SUB", + "SUP", + "U", + "BR", + "SPAN", + "MARK", + "BDO", +]) + +const ALLOWED_LINK_ATTRS = new Set(["href", "target", "rel"]) +const ALLOWED_DIR_VALUES = new Set(["ltr", "rtl", "auto"]) +const ROOT_WRAPPER_TAGS = new Set(["p", "div", "span"]) +const SAFE_COLOR_KEYWORDS = new Set(["currentcolor", "inherit", "initial", "transparent", "unset"]) +const SAFE_INLINE_CLASS_PATTERNS = [ + /^has-[a-z0-9]+(?:-[a-z0-9]+)*-color$/, + /^has-[a-z0-9]+(?:-[a-z0-9]+)*-font-size$/, +] +const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i +const COLOR_FUNCTION_PATTERN = /^(?:rgba?|hsla?)\(\s*[-\d.%\s,/]+\)$/i +const FONT_SIZE_PATTERN = /^(?:0|\d*\.?\d+(?:px|em|rem|%|pt|pc|mm|cm|in|vh|vw|vmin|vmax|ch|ex))$/i +const CSS_VARIABLE_PATTERN = + /^var\(\s*--wp--preset--(?:color|font-size)--[a-z0-9]+(?:-[a-z0-9]+)*\s*\)$/i +const CSS_IDENTIFIER_PATTERN = /^[a-z]+$/i +const UNSAFE_CSS_VALUE_PATTERN = /(?:expression|url|javascript:|data:|@import|<|>|\\)/i +const LANGUAGE_TAG_VALUE_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/ + +const DANGEROUS_DROP_TAGS = new Set(["SCRIPT", "STYLE", "IFRAME", "SVG", "MATH", "TEMPLATE"]) +const BLOCK_TAGS = new Set([ + "P", + "DIV", + "SECTION", + "ARTICLE", + "ASIDE", + "BLOCKQUOTE", + "PRE", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "UL", + "OL", + "LI", +]) +const RECOGNIZED_HTML_TAGS = new Set( + Array.from(INLINE_TAGS) + .concat(Array.from(BLOCK_TAGS)) + .map((tagName) => tagName.toLowerCase()), +) +const HTML_TOKEN_REGEX = /|<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*?)?>/g +const DANGEROUS_TAG_PAIR_REGEX = new RegExp( + `<(${Array.from(DANGEROUS_DROP_TAGS) + .map((tagName) => tagName.toLowerCase()) + .join("|")})\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>`, + "gi", +) + +const DOMPURIFY_CONFIG: DOMPurify.Config = { + ALLOWED_TAGS: [ + "a", + "strong", + "b", + "em", + "i", + "code", + "kbd", + "s", + "sub", + "sup", + "u", + "br", + "span", + "mark", + "bdo", + ], + ALLOWED_ATTR: ["href", "target", "rel", "class", "style", "lang", "dir"], + ALLOW_DATA_ATTR: false, + ALLOW_ARIA_ATTR: false, + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|#|[^a-z]|[a-z+.]+(?:[^a-z+:]|$))/i, +} + +export interface SanitizeParagraphHtmlOptions { + allowedTagNames?: Iterable +} + +/** + * Collects the safe HTML tag names already present in a paragraph, so suggestion + * sanitization can preserve existing markup without treating new literal tag + * examples as authored HTML. + */ +export const collectParagraphHtmlTagNames = (html: string): Set => { + const tagNames = new Set() + const tokenRegex = new RegExp(HTML_TOKEN_REGEX.source, HTML_TOKEN_REGEX.flags) + let match: RegExpExecArray | null = tokenRegex.exec(html) + + while (match) { + const parsedToken = parseHtmlToken(match[0]) + if (parsedToken && RECOGNIZED_HTML_TAGS.has(parsedToken.tagName)) { + tagNames.add(parsedToken.tagName) + } + + match = tokenRegex.exec(html) + } + + return tagNames +} + +/** Sanitizes and normalizes AI-generated HTML fragment for Gutenberg paragraphs. */ +export const sanitizeParagraphHtml = ( + html: string, + options: SanitizeParagraphHtmlOptions = {}, +): string => { + if (!html) { + return "" + } + + if (typeof window === "undefined" || typeof window.DOMParser === "undefined") { + throw new Error("sanitizeParagraphHtml requires a browser environment with DOMParser") + } + + const allowedTagNames = normalizeAllowedTagNames(options.allowedTagNames) + const preparedHtml = prepareHtmlForParsing(stripDangerousTagPairs(html), allowedTagNames) + const parser = new window.DOMParser() + const doc = parser.parseFromString(preparedHtml, "text/html") + const body = doc.body + + let root: HTMLElement | null = null + + if (body.children.length === 1 && body.firstElementChild) { + const only = body.firstElementChild as HTMLElement + if (only.tagName === "P") { + root = only + } else if ((only.tagName === "DIV" || only.tagName === "SPAN") && !only.attributes.length) { + root = only + } + } + + const fragmentContainer = doc.createElement("div") + + if (root) { + while (root.firstChild) { + fragmentContainer.appendChild(root.firstChild) + } + } else { + while (body.firstChild) { + fragmentContainer.appendChild(body.firstChild) + } + } + + const sanitizedContainer = doc.createElement("div") + appendSanitizedNodes(sanitizedContainer, Array.from(fragmentContainer.childNodes)) + normalizeBreaks(sanitizedContainer) + + const structuralHtml = sanitizedContainer.innerHTML + return DOMPurify.sanitize(structuralHtml, DOMPURIFY_CONFIG) +} + +const normalizeBreaks = (container: HTMLElement): void => { + while (container.firstChild && isTrimmableBreak(container.firstChild)) { + container.removeChild(container.firstChild) + } + while (container.lastChild && isTrimmableBreak(container.lastChild)) { + container.removeChild(container.lastChild) + } +} + +const isTrimmableBreak = (node: Node): boolean => { + if (node.nodeType === Node.TEXT_NODE) { + return !node.textContent || node.textContent.trim() === "" + } + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement + return el.tagName === "BR" + } + return false +} + +const appendSanitizedNodes = (target: HTMLElement, nodes: Node[]): void => { + for (let index = 0; index < nodes.length; index += 1) { + const node = nodes[index] + + if (node.nodeType === Node.TEXT_NODE) { + target.appendChild(target.ownerDocument.createTextNode(node.textContent ?? "")) + continue + } + + const renderKind = getRenderableKind(node) + if (renderKind === "skip") { + continue + } + + const element = node as HTMLElement + const tagName = element.tagName + + if (INLINE_TAGS.has(tagName)) { + const clone = cloneAllowedInlineElement(target.ownerDocument, element) + appendSanitizedNodes(clone, Array.from(element.childNodes)) + target.appendChild(clone) + continue + } + + const breakCount = tagName === "LI" ? 1 : 2 + ensureLeadingBreaks(target, breakCount) + + if (tagName === "UL" || tagName === "OL") { + appendListItems(target, element, tagName === "OL") + } else if (tagName === "LI") { + appendListItem(target, element, null) + } else { + appendSanitizedNodes(target, Array.from(element.childNodes)) + } + + const nextRenderableKind = findNextRenderableKind(nodes, index + 1) + if (renderKind === "block" && nextRenderableKind === "inline") { + ensureLeadingBreaks(target, breakCount) + } + } +} + +const appendListItems = (target: HTMLElement, listElement: HTMLElement, ordered: boolean): void => { + let listIndex = 1 + + for (const child of Array.from(listElement.childNodes)) { + if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName === "LI") { + appendListItem(target, child as HTMLElement, ordered ? listIndex : null) + listIndex += 1 + continue + } + + appendSanitizedNodes(target, [child]) + } +} + +const appendListItem = ( + target: HTMLElement, + listItemElement: HTMLElement, + orderedIndex: number | null, +): void => { + ensureLeadingBreaks(target, 1) + + const marker = orderedIndex === null ? "- " : `${orderedIndex}. ` + target.appendChild(target.ownerDocument.createTextNode(marker)) + appendSanitizedNodes(target, Array.from(listItemElement.childNodes)) +} + +const cloneAllowedInlineElement = (doc: Document, element: HTMLElement): HTMLElement => { + const clone = doc.createElement(element.tagName.toLowerCase()) + + if (element.tagName === "A") { + for (const attr of Array.from(element.attributes)) { + if (ALLOWED_LINK_ATTRS.has(attr.name)) { + clone.setAttribute(attr.name, attr.value) + } + } + return clone + } + + if (element.tagName === "SPAN" || element.tagName === "MARK") { + copySafeInlineFormattingAttributes(clone, element) + return clone + } + + if (element.tagName === "BDO") { + copySafeLanguageAttributes(clone, element) + return clone + } + + return clone +} + +const copySafeInlineFormattingAttributes = (clone: HTMLElement, element: HTMLElement): void => { + const className = sanitizeAllowedInlineClassNames(element.getAttribute("class") ?? "") + if (className) { + clone.setAttribute("class", className) + } + + const style = sanitizeAllowedInlineStyle(element.getAttribute("style") ?? "") + if (style) { + clone.setAttribute("style", style) + } +} + +const copySafeLanguageAttributes = (clone: HTMLElement, element: HTMLElement): void => { + const lang = element.getAttribute("lang")?.trim() + if (lang && LANGUAGE_TAG_VALUE_PATTERN.test(lang)) { + clone.setAttribute("lang", lang) + } + + const dir = element.getAttribute("dir")?.trim().toLowerCase() + if (dir && ALLOWED_DIR_VALUES.has(dir)) { + clone.setAttribute("dir", dir) + } +} + +const sanitizeAllowedInlineClassNames = (className: string): string => + className + .split(/\s+/) + .filter(Boolean) + .filter((token) => { + if (token === "has-inline-color" || token === "has-custom-font-size") { + return true + } + + return SAFE_INLINE_CLASS_PATTERNS.some((pattern) => pattern.test(token)) + }) + .join(" ") + +const sanitizeAllowedInlineStyle = (style: string): string => + style + .split(";") + .map((rule) => rule.trim()) + .filter(Boolean) + .flatMap((rule) => { + const separatorIndex = rule.indexOf(":") + if (separatorIndex < 0) { + return [] + } + + const property = rule.slice(0, separatorIndex).trim().toLowerCase() + const value = rule.slice(separatorIndex + 1).trim() + + if (!value || !isSafeInlineStyleValue(property, value)) { + return [] + } + + return [`${property}:${value}`] + }) + .join(";") + +const isSafeInlineStyleValue = (property: string, value: string): boolean => { + switch (property) { + case "background-color": + case "color": + return isSafeColorValue(value) + case "font-size": + return isSafeFontSizeValue(value) + case "text-decoration": + return value.trim().replace(/\s+/g, " ").toLowerCase() === "underline" + default: + return false + } +} + +const isSafeColorValue = (value: string): boolean => { + const normalizedValue = value.trim() + if (!normalizedValue || UNSAFE_CSS_VALUE_PATTERN.test(normalizedValue)) { + return false + } + + return ( + HEX_COLOR_PATTERN.test(normalizedValue) || + COLOR_FUNCTION_PATTERN.test(normalizedValue) || + CSS_VARIABLE_PATTERN.test(normalizedValue) || + SAFE_COLOR_KEYWORDS.has(normalizedValue.toLowerCase()) || + CSS_IDENTIFIER_PATTERN.test(normalizedValue) + ) +} + +const isSafeFontSizeValue = (value: string): boolean => { + const normalizedValue = value.trim() + if (!normalizedValue || UNSAFE_CSS_VALUE_PATTERN.test(normalizedValue)) { + return false + } + + return FONT_SIZE_PATTERN.test(normalizedValue) || CSS_VARIABLE_PATTERN.test(normalizedValue) +} + +const ensureLeadingBreaks = (target: HTMLElement, requiredBreaks: number): void => { + if (!hasMeaningfulContent(target)) { + return + } + + const trailingBreakCount = countTrailingBreaks(target) + for (let index = trailingBreakCount; index < requiredBreaks; index += 1) { + target.appendChild(target.ownerDocument.createElement("br")) + } +} + +const hasMeaningfulContent = (container: HTMLElement): boolean => { + for (const child of Array.from(container.childNodes)) { + if (getRenderableKind(child) !== "skip") { + return true + } + } + + return false +} + +const countTrailingBreaks = (container: HTMLElement): number => { + let breakCount = 0 + let currentNode = container.lastChild + + while (currentNode) { + if (currentNode.nodeType === Node.TEXT_NODE && !currentNode.textContent?.trim()) { + currentNode = currentNode.previousSibling + continue + } + + if ( + currentNode.nodeType === Node.ELEMENT_NODE && + (currentNode as HTMLElement).tagName === "BR" + ) { + breakCount += 1 + currentNode = currentNode.previousSibling + continue + } + + break + } + + return breakCount +} + +const findNextRenderableKind = (nodes: Node[], startIndex: number): "skip" | "inline" | "block" => { + for (let index = startIndex; index < nodes.length; index += 1) { + const renderKind = getRenderableKind(nodes[index]) + if (renderKind !== "skip") { + return renderKind + } + } + + return "skip" +} + +const getRenderableKind = (node: Node): "skip" | "inline" | "block" => { + if (node.nodeType === Node.COMMENT_NODE) { + return "skip" + } + + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.trim() ? "inline" : "skip" + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return "skip" + } + + const element = node as HTMLElement + if (DANGEROUS_DROP_TAGS.has(element.tagName)) { + return "skip" + } + + if (INLINE_TAGS.has(element.tagName)) { + return "inline" + } + + return BLOCK_TAGS.has(element.tagName) ? "block" : "inline" +} + +const prepareHtmlForParsing = (html: string, allowedTagNames: Set | null): string => { + let preparedHtml = "" + let lastIndex = 0 + const openTagCounts = new Map() + const tokenRegex = new RegExp(HTML_TOKEN_REGEX.source, HTML_TOKEN_REGEX.flags) + let match: RegExpExecArray | null = tokenRegex.exec(html) + + while (match) { + const token = match[0] + const matchIndex = match.index ?? 0 + + preparedHtml += escapeHtmlText(html.slice(lastIndex, matchIndex)) + + if (token.startsWith("

Text

") + expect(result).toBe("Text") + }) + + it("preserves multiple allowed inline tags together", () => { + const result = sanitizeParagraphHtml( + "Hello world and xEscold12", + ) + expect(result).toBe( + "Hello world and xEscold12", + ) + }) + + it("drops script tag and its contents entirely", () => { + const result = sanitizeParagraphHtml('SafeText') + expect(result).toBe("SafeText") + expect(result).not.toContain("script") + expect(result).not.toContain("alert") + }) + + it("drops iframe subtree", () => { + const result = sanitizeParagraphHtml( + 'BeforeAfter', + ) + expect(result).toBe("BeforeAfter") + expect(result).not.toContain("iframe") + }) + + it("sanitizes javascript href URLs on links", () => { + const result = sanitizeParagraphHtml('Click') + expect(result).toContain("Click") + expect(result).not.toContain("javascript:") + }) + + it("sanitizes data URLs on links", () => { + const result = sanitizeParagraphHtml( + 'Click', + ) + expect(result).toContain("Click") + expect(result).not.toContain("data:") + }) + + it("removes event handler attributes from non-link inline tags", () => { + const result = sanitizeParagraphHtml('Bold') + expect(result).toBe("Bold") + expect(result).not.toContain("onclick") + }) + + it("handles malformed markup by relying on browser repair plus sanitization", () => { + const result = sanitizeParagraphHtml("Unclosed") + expect(result).toContain("") + expect(result).toContain("Unclosed") + }) + + it("preserves literal angle brackets in plain-text suggestions", () => { + const result = sanitizeParagraphHtml("Vec and use
tags") + expect(result).toBe("Vec<T> and use <div> tags") + }) + + it("preserves literal angle brackets alongside inline markup", () => { + const result = sanitizeParagraphHtml("Use
tags and emphasis") + expect(result).toBe("Use <div> tags and emphasis") + }) + + it("preserves paragraph separators when flattening block elements", () => { + const result = sanitizeParagraphHtml("

One

Two

") + expect(result).toBe("One

Two") + }) + + it("preserves list item separators when flattening list markup", () => { + const result = sanitizeParagraphHtml("
  • One
  • Two
") + expect(result).toBe("- One
- Two") + }) + + it("escapes literal html examples that were not present in the original paragraph html", () => { + const allowedTagNames = collectParagraphHtmlTagNames( + 'See docs and <em>text</em>', + ) + + const result = sanitizeParagraphHtml('See docs and text', { + allowedTagNames, + }) + + expect(result).toBe('See docs and <em>text</em>') + }) + + it("allows root wrappers while escaping new html in plain-text suggestions", () => { + const allowedTagNames = collectParagraphHtmlTagNames("Plain paragraph") + + expect(sanitizeParagraphHtml("

Plain paragraph

", { allowedTagNames })).toBe( + "Plain paragraph", + ) + expect(sanitizeParagraphHtml("Use
", { allowedTagNames })).toBe( + "Use <div></div>", + ) + }) +}) diff --git a/services/headless-lms/chatbot/src/cms_ai_suggestion.rs b/services/headless-lms/chatbot/src/cms_ai_suggestion.rs new file mode 100644 index 00000000000..3112a7a9f9a --- /dev/null +++ b/services/headless-lms/chatbot/src/cms_ai_suggestion.rs @@ -0,0 +1,198 @@ +use std::collections::HashMap; + +use crate::{ + azure_chatbot::{ + ArrayItem, ArrayProperty, JSONSchema, JSONType, LLMRequest, LLMRequestParams, + LLMRequestResponseFormatParam, NonThinkingParams, Schema, ThinkingParams, + }, + content_cleaner::calculate_safe_token_limit, + llm_utils::{ + APIMessage, APIMessageKind, APIMessageText, estimate_tokens, make_blocking_llm_request, + parse_text_completion, + }, + prelude::{ChatbotError, ChatbotErrorType, ChatbotResult}, +}; +use headless_lms_models::application_task_default_language_models::TaskLMSpec; +use headless_lms_models::chatbot_conversation_messages::MessageRole; +use headless_lms_utils::{ApplicationConfiguration, prelude::BackendError}; + +/// Structured LLM response for CMS paragraph suggestions. +#[derive(serde::Deserialize)] +struct CmsParagraphSuggestionResponse { + suggestions: Vec, +} + +/// System prompt for generating multiple alternative paragraph suggestions for CMS content. +const SYSTEM_PROMPT: &str = r#"You are helping course staff improve a single paragraph of course material. + +Your task is to generate several alternative versions of the given paragraph based on the requested action. + +General rules: +- Always preserve the original meaning and important details unless the action explicitly asks to add or remove content. +- Maintain a clear, pedagogical tone appropriate for course materials. +- Do not invent facts that contradict the original paragraph. + +About the suggestions: +- Produce multiple alternative rewrites of the same paragraph. +- Each suggestion must be meaningfully different in structure, emphasis, or level of detail. +- Do NOT output suggestions that only differ by tiny edits (e.g. one or two word changes). +- Keep each suggestion self-contained and suitable for direct insertion into the material. + +You will receive: +- The original paragraph text. +- The requested action (e.g. fix spelling, improve clarity, change tone, translate). +- Optional metadata such as target tone and target language. + +Your output must follow the JSON schema exactly: +{ + "suggestions": ["...", "...", "..."] +}"#; + +/// User prompt prefix; the concrete action and metadata will be appended. +const USER_PROMPT_PREFIX: &str = "Generate multiple rewritten versions of the paragraph according to the requested action and metadata. The paragraph may contain inline HTML markup valid inside a Gutenberg paragraph; preserve existing inline tags (links, emphasis, code, sub/superscripts) where possible, do not introduce block-level elements, and do not add new formatting to spans of text that were previously unformatted. Return JSON only."; + +/// Input payload for CMS paragraph suggestions. +pub struct CmsParagraphSuggestionInput { + pub action: String, + pub content: String, + pub is_html: bool, + pub meta_tone: Option, + pub meta_language: Option, + pub meta_setting_type: Option, +} + +/// Generate multiple paragraph suggestions for CMS using an LLM with structured JSON output. +pub async fn generate_paragraph_suggestions( + app_config: &ApplicationConfiguration, + task_lm: TaskLMSpec, + input: &CmsParagraphSuggestionInput, +) -> ChatbotResult> { + let CmsParagraphSuggestionInput { + action, + content, + is_html: _, + meta_tone, + meta_language, + meta_setting_type, + } = input; + + let mut system_instructions = SYSTEM_PROMPT.to_owned(); + system_instructions.push_str("\n\nRequested action: "); + system_instructions.push_str(action); + if let Some(tone) = meta_tone { + system_instructions.push_str("\nTarget tone: "); + system_instructions.push_str(tone); + } + if let Some(lang) = meta_language { + system_instructions.push_str("\nTarget language: "); + system_instructions.push_str(lang); + } + if let Some(setting_type) = meta_setting_type { + system_instructions.push_str("\nSetting type: "); + system_instructions.push_str(setting_type); + } + + let paragraph_source = content.as_str(); + + let user_message_content = format!( + "{prefix}\n\nOriginal paragraph (may include inline HTML):\n{paragraph}", + prefix = USER_PROMPT_PREFIX, + paragraph = paragraph_source + ); + + let used_tokens = + estimate_tokens(&system_instructions) + estimate_tokens(&user_message_content); + let token_budget = + calculate_safe_token_limit(task_lm.context_size, task_lm.context_utilization); + + if used_tokens > token_budget { + return Err(ChatbotError::new( + ChatbotErrorType::ChatbotMessageSuggestError, + "Input paragraph is too long for the CMS AI suggestion context window.".to_string(), + None, + )); + } + + let system_message = APIMessage { + role: MessageRole::System, + fields: APIMessageKind::Text(APIMessageText { + content: system_instructions, + }), + }; + + let user_message = APIMessage { + role: MessageRole::User, + fields: APIMessageKind::Text(APIMessageText { + content: user_message_content, + }), + }; + + let params = if task_lm.thinking { + LLMRequestParams::Thinking(ThinkingParams { + max_completion_tokens: Some(4000), + verbosity: None, + reasoning_effort: None, + tools: vec![], + tool_choice: None, + }) + } else { + LLMRequestParams::NonThinking(NonThinkingParams { + max_tokens: Some(2000), + temperature: None, + top_p: None, + frequency_penalty: None, + presence_penalty: None, + }) + }; + + let chat_request = LLMRequest { + messages: vec![system_message, user_message], + data_sources: vec![], + params, + response_format: Some(LLMRequestResponseFormatParam { + format_type: JSONType::JsonSchema, + json_schema: JSONSchema { + name: "CmsParagraphSuggestionResponse".to_string(), + strict: true, + schema: Schema { + type_field: JSONType::Object, + properties: HashMap::from([( + "suggestions".to_string(), + ArrayProperty { + type_field: JSONType::Array, + items: ArrayItem { + type_field: JSONType::String, + }, + }, + )]), + required: vec!["suggestions".to_string()], + additional_properties: false, + }, + }, + }), + stop: None, + }; + + let completion = make_blocking_llm_request(chat_request, app_config, &task_lm).await?; + + let completion_content: &String = &parse_text_completion(completion)?; + let response: CmsParagraphSuggestionResponse = serde_json::from_str(completion_content) + .map_err(|_| { + ChatbotError::new( + ChatbotErrorType::ChatbotMessageSuggestError, + "The CMS paragraph suggestion LLM returned an incorrectly formatted response." + .to_string(), + None, + ) + })?; + + if response.suggestions.is_empty() { + return Err(ChatbotError::new( + ChatbotErrorType::ChatbotMessageSuggestError, + "The CMS paragraph suggestion LLM returned an empty suggestions list.".to_string(), + None, + )); + } + + Ok(response.suggestions) +} diff --git a/services/headless-lms/chatbot/src/lib.rs b/services/headless-lms/chatbot/src/lib.rs index b69dc9f02a4..61e6b94c519 100644 --- a/services/headless-lms/chatbot/src/lib.rs +++ b/services/headless-lms/chatbot/src/lib.rs @@ -8,6 +8,7 @@ pub mod azure_search_indexer; pub mod azure_skillset; pub mod chatbot_error; pub mod chatbot_tools; +pub mod cms_ai_suggestion; pub mod content_cleaner; pub mod llm_utils; pub mod message_suggestion; diff --git a/services/headless-lms/migrations/20260304120000_add_cms_paragraph_suggestion_task.down.sql b/services/headless-lms/migrations/20260304120000_add_cms_paragraph_suggestion_task.down.sql new file mode 100644 index 00000000000..2e939720f61 --- /dev/null +++ b/services/headless-lms/migrations/20260304120000_add_cms_paragraph_suggestion_task.down.sql @@ -0,0 +1,12 @@ +DELETE FROM application_task_default_language_models +WHERE task = 'cms-paragraph-suggestion'; + +ALTER TYPE application_task +RENAME TO application_task_old; + +CREATE TYPE application_task AS ENUM ('content-cleaning', 'message-suggestion'); + +ALTER TABLE application_task_default_language_models +ALTER COLUMN task TYPE application_task USING task::text::application_task; + +DROP TYPE application_task_old; diff --git a/services/headless-lms/migrations/20260304120000_add_cms_paragraph_suggestion_task.up.sql b/services/headless-lms/migrations/20260304120000_add_cms_paragraph_suggestion_task.up.sql new file mode 100644 index 00000000000..f0dbb412939 --- /dev/null +++ b/services/headless-lms/migrations/20260304120000_add_cms_paragraph_suggestion_task.up.sql @@ -0,0 +1,2 @@ +ALTER TYPE application_task +ADD VALUE IF NOT EXISTS 'cms-paragraph-suggestion'; diff --git a/services/headless-lms/models/src/application_task_default_language_models.rs b/services/headless-lms/models/src/application_task_default_language_models.rs index 8cdbe386dec..e4904ae1d6c 100644 --- a/services/headless-lms/models/src/application_task_default_language_models.rs +++ b/services/headless-lms/models/src/application_task_default_language_models.rs @@ -5,6 +5,7 @@ use crate::prelude::*; pub enum ApplicationTask { ContentCleaning, MessageSuggestion, + CmsParagraphSuggestion, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] diff --git a/services/headless-lms/models/src/user_chapter_locking_statuses.rs b/services/headless-lms/models/src/user_chapter_locking_statuses.rs index 66b7aaa4d44..2d7c66f8a7c 100644 --- a/services/headless-lms/models/src/user_chapter_locking_statuses.rs +++ b/services/headless-lms/models/src/user_chapter_locking_statuses.rs @@ -565,7 +565,7 @@ mod tests { #[tokio::test] async fn get_or_init_all_for_course_unlocks_first_chapter_when_all_not_unlocked_yet() { - insert_data!(:tx, :user, :org, course: course, instance: _instance, :course_module); + insert_data!(:tx, :user, :org, course: course); let all_modules = crate::course_modules::get_by_course_id(tx.as_mut(), course) .await diff --git a/services/headless-lms/server/src/controllers/cms/ai_suggestions.rs b/services/headless-lms/server/src/controllers/cms/ai_suggestions.rs new file mode 100644 index 00000000000..141135e28e1 --- /dev/null +++ b/services/headless-lms/server/src/controllers/cms/ai_suggestions.rs @@ -0,0 +1,158 @@ +//! Controllers for requests starting with `/api/v0/cms/ai-suggestions`. +use headless_lms_models::application_task_default_language_models::{self, ApplicationTask}; + +use crate::prelude::*; + +const ALLOWED_PARAGRAPH_ACTIONS: &[&str] = &[ + "moocfi/ai/generate-draft-from-notes", + "moocfi/ai/generate-continue-paragraph", + "moocfi/ai/generate-add-example", + "moocfi/ai/generate-add-counterpoint", + "moocfi/ai/generate-add-concluding-sentence", + "moocfi/fix-spelling", + "moocfi/ai/improve-clarity", + "moocfi/ai/improve-flow", + "moocfi/ai/improve-concise", + "moocfi/ai/improve-expand-detail", + "moocfi/ai/improve-academic-style", + "moocfi/ai/structure-create-topic-sentence", + "moocfi/ai/structure-reorder-sentences", + "moocfi/ai/structure-split-into-paragraphs", + "moocfi/ai/structure-combine-into-one", + "moocfi/ai/structure-to-bullets", + "moocfi/ai/structure-from-bullets", + "moocfi/ai/learning-simplify-beginners", + "moocfi/ai/learning-add-definitions", + "moocfi/ai/learning-add-analogy", + "moocfi/ai/learning-add-practice-question", + "moocfi/ai/learning-add-check-understanding", + "moocfi/ai/summaries-one-sentence", + "moocfi/ai/summaries-two-three-sentences", + "moocfi/ai/summaries-key-takeaway", + "moocfi/ai/tone-academic-formal", + "moocfi/ai/tone-friendly-conversational", + "moocfi/ai/tone-encouraging-supportive", + "moocfi/ai/tone-neutral-objective", + "moocfi/ai/tone-confident", + "moocfi/ai/tone-serious", + "moocfi/ai/translate-english", + "moocfi/ai/translate-finnish", + "moocfi/ai/translate-swedish", +]; + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct ParagraphSuggestionMeta { + pub tone: Option, + pub language: Option, + pub setting_type: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct ParagraphSuggestionContext { + pub page_id: Option, + pub course_id: Option, + pub locale: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct ParagraphSuggestionRequest { + pub action: String, + pub content: String, + pub is_html: bool, + pub meta: Option, + pub context: Option, +} + +#[derive(Serialize, Deserialize)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct ParagraphSuggestionResponse { + pub suggestions: Vec, +} + +/** +POST `/api/v0/cms/ai-suggestions/paragraph` - Generate AI suggestions for a CMS paragraph. + +This endpoint is intended for CMS editors. It requires the user to have edit access +to the referenced page when `context.page_id` is provided, otherwise it falls back +to requiring a teaching role for some course via `Res::AnyCourse`. +*/ +#[instrument(skip(pool, app_conf))] +async fn suggest_paragraph( + pool: web::Data, + app_conf: web::Data, + user: AuthUser, + payload: web::Json, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + + // Basic validation of input content. + if payload.content.trim().is_empty() { + return Err(ControllerError::new( + ControllerErrorType::BadRequest, + "Paragraph content must not be empty.".to_string(), + None, + )); + } + + if !ALLOWED_PARAGRAPH_ACTIONS.contains(&payload.action.as_str()) { + return Err(ControllerError::new( + ControllerErrorType::BadRequest, + "Unsupported paragraph suggestion action.".to_string(), + None, + )); + } + + // Authorize: prefer page-level edit permission when page_id is available, + // otherwise require that the user can teach at least one course. + let token = if let Some(ParagraphSuggestionContext { + page_id: Some(page_id), + .. + }) = &payload.context + { + authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(*page_id)).await? + } else { + authorize(&mut conn, Act::Teach, Some(user.id), Res::AnyCourse).await? + }; + + let task_lm = application_task_default_language_models::get_for_task( + &mut conn, + ApplicationTask::CmsParagraphSuggestion, + ) + .await?; + + let meta = payload.meta.as_ref(); + let generator_input = headless_lms_chatbot::cms_ai_suggestion::CmsParagraphSuggestionInput { + action: payload.action.clone(), + content: payload.content.clone(), + is_html: payload.is_html, + meta_tone: meta.and_then(|m| m.tone.clone()), + meta_language: meta.and_then(|m| m.language.clone()), + meta_setting_type: meta.and_then(|m| m.setting_type.clone()), + }; + + // Return the DB connection to the pool before the LLM call. + drop(conn); + + let suggestions = headless_lms_chatbot::cms_ai_suggestion::generate_paragraph_suggestions( + &app_conf, + task_lm, + &generator_input, + ) + .await?; + + token.authorized_ok(web::Json(ParagraphSuggestionResponse { suggestions })) +} + +/** +Add a route for each controller in this module. + +The name starts with an underline in order to appear before other functions in the module documentation. + +We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation. +*/ +pub fn _add_routes(cfg: &mut ServiceConfig) { + cfg.route("/paragraph", web::post().to(suggest_paragraph)); +} diff --git a/services/headless-lms/server/src/controllers/cms/mod.rs b/services/headless-lms/server/src/controllers/cms/mod.rs index 60469bfc757..62d8d7df35a 100644 --- a/services/headless-lms/server/src/controllers/cms/mod.rs +++ b/services/headless-lms/server/src/controllers/cms/mod.rs @@ -5,6 +5,7 @@ This documents all endpoints. Select a module below for a category. */ +pub mod ai_suggestions; pub mod chapters; pub mod code_giveaways; pub mod course_instances; @@ -31,5 +32,6 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { .service(web::scope("/exams").configure(exams::_add_routes)) .service(web::scope("/exercise-services").configure(exercise_services::_add_routes)) .service(web::scope("/code-giveaways").configure(code_giveaways::_add_routes)) - .service(web::scope("/repository-exercises").configure(repository_exercises::_add_routes)); + .service(web::scope("/repository-exercises").configure(repository_exercises::_add_routes)) + .service(web::scope("/ai-suggestions").configure(ai_suggestions::_add_routes)); } diff --git a/services/headless-lms/server/src/programs/seed/seed_application_task_llms.rs b/services/headless-lms/server/src/programs/seed/seed_application_task_llms.rs index ce48696bc14..9987044e767 100644 --- a/services/headless-lms/server/src/programs/seed/seed_application_task_llms.rs +++ b/services/headless-lms/server/src/programs/seed/seed_application_task_llms.rs @@ -52,6 +52,17 @@ pub async fn seed_application_task_llms( ) .await?; + application_task_default_language_models::insert( + &mut conn, + ApplicationTaskDefaultLanguageModel { + model_id: llm.id, + task: ApplicationTask::CmsParagraphSuggestion, + context_utilization: 0.75, + ..Default::default() + }, + ) + .await?; + Ok(SeedApplicationLLMsResult { llm_default_model_id: llm.id, llm_default_model_thinking: llm.thinking, diff --git a/services/headless-lms/server/src/ts_binding_generator.rs b/services/headless-lms/server/src/ts_binding_generator.rs index 7e01f2caeee..92597510c7e 100644 --- a/services/headless-lms/server/src/ts_binding_generator.rs +++ b/services/headless-lms/server/src/ts_binding_generator.rs @@ -391,6 +391,19 @@ fn controllers(target: &mut File) { }; } + // cms + { + use cms::*; + export! { + target, + + ai_suggestions::ParagraphSuggestionMeta, + ai_suggestions::ParagraphSuggestionContext, + ai_suggestions::ParagraphSuggestionRequest, + ai_suggestions::ParagraphSuggestionResponse, + }; + } + // domain { use crate::domain::*; diff --git a/shared-module/packages/common/src/bindings.guard.ts b/shared-module/packages/common/src/bindings.guard.ts index 73b19dfff56..a807ded84f5 100644 --- a/shared-module/packages/common/src/bindings.guard.ts +++ b/shared-module/packages/common/src/bindings.guard.ts @@ -5,7 +5,7 @@ * Generated type guards for "bindings.ts". * WARNING: Do not manually change this file. */ -import { Action, ActionOnResource, Resource, ErrorData, ErrorResponse, SpecRequest, ConsentQuery, ConsentResponse, ConsentDenyQuery, CertificateAllRequirements, CertificateConfiguration, CertificateConfigurationAndRequirements, CertificateTextAnchor, PaperSize, Chapter, ChapterStatus, ChapterUpdate, ChapterWithStatus, DatabaseChapter, NewChapter, UserCourseInstanceChapterProgress, ChapterAvailability, UserChapterProgress, CourseUserInfo, ChapterLockPreview, UnreturnedExercise, ChatbotConfiguration, NewChatbotConf, VerbosityLevel, ReasoningEffortLevel, ChatbotConfigurationModel, ChatbotConversationMessage, MessageRole, ChatbotConversationMessageCitation, ChatbotConversationMessageToolCall, ChatbotConversationMessageToolOutput, ChatbotConversationSuggestedMessage, ChatbotConversation, ChatbotConversationInfo, CodeGiveawayCode, CodeGiveaway, CodeGiveawayStatus, NewCodeGiveaway, CourseBackgroundQuestionAnswer, NewCourseBackgroundQuestionAnswer, CourseBackgroundQuestion, CourseBackgroundQuestionType, CourseBackgroundQuestionsAndAnswers, CourseCustomPrivacyPolicyCheckboxText, CourseEnrollmentInfo, CourseEnrollmentsInfo, CourseInstanceEnrollment, CourseInstanceEnrollmentsInfo, ChapterScore, CourseInstance, CourseInstanceForm, PointMap, Points, CourseModuleCompletion, CourseModuleCompletionWithRegistrationInfo, AutomaticCompletionRequirements, CompletionPolicy, CourseModule, ModifiedModule, ModuleUpdates, NewCourseModule, NewModule, Course, CourseMaterialCourse, CourseBreadcrumbInfo, CourseCount, CourseStructure, CourseUpdate, NewCourse, CourseLanguageVersionNavigationInfo, EmailTemplate, EmailTemplateNew, EmailTemplateUpdate, EmailTemplateType, CourseExam, Exam, ExamEnrollment, ExamInstructions, ExamInstructionsUpdate, NewExam, OrgExam, ExerciseRepository, ExerciseRepositoryStatus, CourseMaterialExerciseServiceInfo, ExerciseServiceInfoApi, ExerciseService, ExerciseServiceIframeRenderingInfo, ExerciseServiceNewOrUpdate, AnswerRequiringAttention, ExerciseAnswersInCourseRequiringAttentionCount, ExerciseSlideSubmission, ExerciseSlideSubmissionAndUserExerciseState, ExerciseSlideSubmissionAndUserExerciseStateList, ExerciseSlideSubmissionCount, ExerciseSlideSubmissionCountByExercise, ExerciseSlideSubmissionCountByWeekAndHour, ExerciseSlideSubmissionInfo, CourseMaterialExerciseSlide, ExerciseSlide, ExerciseTaskGrading, ExerciseTaskGradingResult, UserPointsUpdateStrategy, ExerciseTaskSubmission, PeerOrSelfReviewsReceived, CourseMaterialExerciseTask, ExerciseTask, ActivityProgress, CourseMaterialExercise, Exercise, ExerciseGradingStatus, ExerciseStatus, ExerciseStatusSummaryForUser, GradingProgress, ExerciseResetLog, Feedback, FeedbackBlock, FeedbackCount, NewFeedback, FlaggedAnswer, NewFlaggedAnswer, NewFlaggedAnswerWithToken, ReportReason, GeneratedCertificate, CertificateUpdateRequest, Term, TermUpdate, AverageMetric, CohortActivity, CountResult, StudentsByCountryTotalsResult, CustomViewExerciseSubmissions, CustomViewExerciseTaskGrading, CustomViewExerciseTaskSpec, CustomViewExerciseTaskSubmission, CustomViewExerciseTasks, CourseCompletionStats, DomainCompletionStats, GlobalCourseModuleStatEntry, GlobalStatEntry, TimeGranularity, AnswerRequiringAttentionWithTasks, AnswersRequiringAttention, StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult, StudentExerciseTaskSubmission, StudentExerciseTaskSubmissionResult, CourseMaterialPeerOrSelfReviewData, CourseMaterialPeerOrSelfReviewDataAnswerToReview, CourseMaterialPeerOrSelfReviewQuestionAnswer, CourseMaterialPeerOrSelfReviewSubmission, CompletionRegistrationLink, CourseInstanceCompletionSummary, ManualCompletionPreview, ManualCompletionPreviewUser, TeacherManualCompletion, TeacherManualCompletionRequest, UserCompletionInformation, UserCourseModuleCompletion, UserModuleCompletionStatus, UserWithModuleCompletions, ProgressOverview, CompletionGridRow, CertificateGridRow, UserMarketingConsent, MaterialReference, NewMaterialReference, Organization, AuthorizedClientInfo, PageAudioFile, HistoryChangeReason, PageHistory, PageVisitDatumSummaryByCourse, PageVisitDatumSummaryByCoursesCountries, PageVisitDatumSummaryByCourseDeviceTypes, PageVisitDatumSummaryByPages, CmsPageExercise, CmsPageExerciseSlide, CmsPageExerciseTask, CmsPageUpdate, ContentManagementPage, CoursePageWithUserData, ExerciseWithExerciseTasks, HistoryRestoreData, IsChapterFrontPage, NewPage, Page, PageChapterAndCourseInformation, PageDetailsUpdate, PageInfo, PageNavigationInformation, PageRoutingData, PageSearchResult, PageWithExercises, SearchRequest, PartnerBlockNew, PartnersBlock, CmsPeerOrSelfReviewConfig, CmsPeerOrSelfReviewConfiguration, CourseMaterialPeerOrSelfReviewConfig, PeerOrSelfReviewConfig, PeerReviewProcessingStrategy, PeerOrSelfReviewAnswer, PeerOrSelfReviewQuestionAndAnswer, PeerOrSelfReviewQuestionSubmission, PeerReviewWithQuestionsAndAnswers, CmsPeerOrSelfReviewQuestion, PeerOrSelfReviewQuestion, PeerOrSelfReviewQuestionType, PeerOrSelfReviewSubmission, PeerOrSelfReviewSubmissionWithSubmissionOwner, PeerReviewQueueEntry, PendingRole, PlaygroundExample, PlaygroundExampleData, PrivacyLink, BlockProposal, BlockProposalAction, BlockProposalInfo, EditedBlockNoLongerExistsData, EditedBlockStillExistsData, NewProposedBlockEdit, ProposalStatus, EditProposalInfo, NewProposedPageEdits, PageProposal, ProposalCount, NewRegrading, NewRegradingIdType, Regrading, RegradingInfo, RegradingSubmissionInfo, RepositoryExercise, NewResearchForm, NewResearchFormQuestion, NewResearchFormQuestionAnswer, ResearchForm, ResearchFormQuestion, ResearchFormQuestionAnswer, RoleDomain, RoleInfo, RoleUser, UserRole, StudentCountry, SuspectedCheaters, ThresholdData, NewTeacherGradingDecision, TeacherDecisionType, TeacherGradingDecision, UserChapterLockingStatus, ChapterLockingStatus, UserCourseExerciseServiceVariable, UserCourseSettings, UserDetail, ExerciseUserCounts, ReviewingStage, UserCourseChapterExerciseProgress, UserCourseProgress, UserExerciseState, UserResearchConsent, User, UploadResult, CreateAccountDetails, Login, LoginResponse, VerifyEmailRequest, UserInfo, SaveCourseSettingsPayload, ChaptersWithStatus, CourseMaterialCourseModule, ExamData, ExamEnrollmentData, CourseMaterialPeerOrSelfReviewDataWithToken, CourseInfo, CertificateConfigurationUpdate, GetFeedbackQuery, CopyCourseRequest, CopyCourseMode, ExamCourseInfo, NewExerciseRepository, ExerciseServiceWithError, ExerciseSubmissions, MarkAsRead, PlaygroundViewsMessage, GetEditProposalsQuery, RoleQuery, BulkUserDetailsRequest, UserDetailsRequest, UserInfoPayload, CronJobInfo, DeploymentInfo, EventInfo, IngressInfo, JobInfo, PodDisruptionBudgetInfo, PodInfo, ServiceInfo, ServicePortInfo, HealthStatus, SystemHealthStatus, Pagination, OEmbedResponse, GutenbergBlock } from "./bindings"; +import { Action, ActionOnResource, Resource, ErrorData, ErrorResponse, SpecRequest, ConsentQuery, ConsentResponse, ConsentDenyQuery, CertificateAllRequirements, CertificateConfiguration, CertificateConfigurationAndRequirements, CertificateTextAnchor, PaperSize, Chapter, ChapterStatus, ChapterUpdate, ChapterWithStatus, DatabaseChapter, NewChapter, UserCourseInstanceChapterProgress, ChapterAvailability, UserChapterProgress, CourseUserInfo, ChapterLockPreview, UnreturnedExercise, ChatbotConfiguration, NewChatbotConf, VerbosityLevel, ReasoningEffortLevel, ChatbotConfigurationModel, ChatbotConversationMessage, MessageRole, ChatbotConversationMessageCitation, ChatbotConversationMessageToolCall, ChatbotConversationMessageToolOutput, ChatbotConversationSuggestedMessage, ChatbotConversation, ChatbotConversationInfo, CodeGiveawayCode, CodeGiveaway, CodeGiveawayStatus, NewCodeGiveaway, CourseBackgroundQuestionAnswer, NewCourseBackgroundQuestionAnswer, CourseBackgroundQuestion, CourseBackgroundQuestionType, CourseBackgroundQuestionsAndAnswers, CourseCustomPrivacyPolicyCheckboxText, CourseEnrollmentInfo, CourseEnrollmentsInfo, CourseInstanceEnrollment, CourseInstanceEnrollmentsInfo, ChapterScore, CourseInstance, CourseInstanceForm, PointMap, Points, CourseModuleCompletion, CourseModuleCompletionWithRegistrationInfo, AutomaticCompletionRequirements, CompletionPolicy, CourseModule, ModifiedModule, ModuleUpdates, NewCourseModule, NewModule, Course, CourseMaterialCourse, CourseBreadcrumbInfo, CourseCount, CourseStructure, CourseUpdate, NewCourse, CourseLanguageVersionNavigationInfo, EmailTemplate, EmailTemplateNew, EmailTemplateUpdate, EmailTemplateType, CourseExam, Exam, ExamEnrollment, ExamInstructions, ExamInstructionsUpdate, NewExam, OrgExam, ExerciseRepository, ExerciseRepositoryStatus, CourseMaterialExerciseServiceInfo, ExerciseServiceInfoApi, ExerciseService, ExerciseServiceIframeRenderingInfo, ExerciseServiceNewOrUpdate, AnswerRequiringAttention, ExerciseAnswersInCourseRequiringAttentionCount, ExerciseSlideSubmission, ExerciseSlideSubmissionAndUserExerciseState, ExerciseSlideSubmissionAndUserExerciseStateList, ExerciseSlideSubmissionCount, ExerciseSlideSubmissionCountByExercise, ExerciseSlideSubmissionCountByWeekAndHour, ExerciseSlideSubmissionInfo, CourseMaterialExerciseSlide, ExerciseSlide, ExerciseTaskGrading, ExerciseTaskGradingResult, UserPointsUpdateStrategy, ExerciseTaskSubmission, PeerOrSelfReviewsReceived, CourseMaterialExerciseTask, ExerciseTask, ActivityProgress, CourseMaterialExercise, Exercise, ExerciseGradingStatus, ExerciseStatus, ExerciseStatusSummaryForUser, GradingProgress, ExerciseResetLog, Feedback, FeedbackBlock, FeedbackCount, NewFeedback, FlaggedAnswer, NewFlaggedAnswer, NewFlaggedAnswerWithToken, ReportReason, GeneratedCertificate, CertificateUpdateRequest, Term, TermUpdate, AverageMetric, CohortActivity, CountResult, StudentsByCountryTotalsResult, CustomViewExerciseSubmissions, CustomViewExerciseTaskGrading, CustomViewExerciseTaskSpec, CustomViewExerciseTaskSubmission, CustomViewExerciseTasks, CourseCompletionStats, DomainCompletionStats, GlobalCourseModuleStatEntry, GlobalStatEntry, TimeGranularity, AnswerRequiringAttentionWithTasks, AnswersRequiringAttention, StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult, StudentExerciseTaskSubmission, StudentExerciseTaskSubmissionResult, CourseMaterialPeerOrSelfReviewData, CourseMaterialPeerOrSelfReviewDataAnswerToReview, CourseMaterialPeerOrSelfReviewQuestionAnswer, CourseMaterialPeerOrSelfReviewSubmission, CompletionRegistrationLink, CourseInstanceCompletionSummary, ManualCompletionPreview, ManualCompletionPreviewUser, TeacherManualCompletion, TeacherManualCompletionRequest, UserCompletionInformation, UserCourseModuleCompletion, UserModuleCompletionStatus, UserWithModuleCompletions, ProgressOverview, CompletionGridRow, CertificateGridRow, UserMarketingConsent, MaterialReference, NewMaterialReference, Organization, AuthorizedClientInfo, PageAudioFile, HistoryChangeReason, PageHistory, PageVisitDatumSummaryByCourse, PageVisitDatumSummaryByCoursesCountries, PageVisitDatumSummaryByCourseDeviceTypes, PageVisitDatumSummaryByPages, CmsPageExercise, CmsPageExerciseSlide, CmsPageExerciseTask, CmsPageUpdate, ContentManagementPage, CoursePageWithUserData, ExerciseWithExerciseTasks, HistoryRestoreData, IsChapterFrontPage, NewPage, Page, PageChapterAndCourseInformation, PageDetailsUpdate, PageInfo, PageNavigationInformation, PageRoutingData, PageSearchResult, PageWithExercises, SearchRequest, PartnerBlockNew, PartnersBlock, CmsPeerOrSelfReviewConfig, CmsPeerOrSelfReviewConfiguration, CourseMaterialPeerOrSelfReviewConfig, PeerOrSelfReviewConfig, PeerReviewProcessingStrategy, PeerOrSelfReviewAnswer, PeerOrSelfReviewQuestionAndAnswer, PeerOrSelfReviewQuestionSubmission, PeerReviewWithQuestionsAndAnswers, CmsPeerOrSelfReviewQuestion, PeerOrSelfReviewQuestion, PeerOrSelfReviewQuestionType, PeerOrSelfReviewSubmission, PeerOrSelfReviewSubmissionWithSubmissionOwner, PeerReviewQueueEntry, PendingRole, PlaygroundExample, PlaygroundExampleData, PrivacyLink, BlockProposal, BlockProposalAction, BlockProposalInfo, EditedBlockNoLongerExistsData, EditedBlockStillExistsData, NewProposedBlockEdit, ProposalStatus, EditProposalInfo, NewProposedPageEdits, PageProposal, ProposalCount, NewRegrading, NewRegradingIdType, Regrading, RegradingInfo, RegradingSubmissionInfo, RepositoryExercise, NewResearchForm, NewResearchFormQuestion, NewResearchFormQuestionAnswer, ResearchForm, ResearchFormQuestion, ResearchFormQuestionAnswer, RoleDomain, RoleInfo, RoleUser, UserRole, StudentCountry, SuspectedCheaters, ThresholdData, NewTeacherGradingDecision, TeacherDecisionType, TeacherGradingDecision, UserChapterLockingStatus, ChapterLockingStatus, UserCourseExerciseServiceVariable, UserCourseSettings, UserDetail, ExerciseUserCounts, ReviewingStage, UserCourseChapterExerciseProgress, UserCourseProgress, UserExerciseState, UserResearchConsent, User, UploadResult, CreateAccountDetails, Login, LoginResponse, VerifyEmailRequest, UserInfo, SaveCourseSettingsPayload, ChaptersWithStatus, CourseMaterialCourseModule, ExamData, ExamEnrollmentData, CourseMaterialPeerOrSelfReviewDataWithToken, CourseInfo, CertificateConfigurationUpdate, GetFeedbackQuery, CopyCourseRequest, CopyCourseMode, ExamCourseInfo, NewExerciseRepository, ExerciseServiceWithError, ExerciseSubmissions, MarkAsRead, PlaygroundViewsMessage, GetEditProposalsQuery, RoleQuery, BulkUserDetailsRequest, UserDetailsRequest, UserInfoPayload, CronJobInfo, DeploymentInfo, EventInfo, IngressInfo, JobInfo, PodDisruptionBudgetInfo, PodInfo, ServiceInfo, ServicePortInfo, ParagraphSuggestionMeta, ParagraphSuggestionContext, ParagraphSuggestionRequest, ParagraphSuggestionResponse, HealthStatus, SystemHealthStatus, Pagination, OEmbedResponse, GutenbergBlock } from "./bindings"; export function isAction(obj: unknown): obj is Action { const typedObj = obj as Action @@ -5561,6 +5561,65 @@ export function isServicePortInfo(obj: unknown): obj is ServicePortInfo { ) } +export function isParagraphSuggestionMeta(obj: unknown): obj is ParagraphSuggestionMeta { + const typedObj = obj as ParagraphSuggestionMeta + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + (typedObj["tone"] === null || + typeof typedObj["tone"] === "string") && + (typedObj["language"] === null || + typeof typedObj["language"] === "string") && + (typedObj["setting_type"] === null || + typeof typedObj["setting_type"] === "string") + ) +} + +export function isParagraphSuggestionContext(obj: unknown): obj is ParagraphSuggestionContext { + const typedObj = obj as ParagraphSuggestionContext + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + (typedObj["page_id"] === null || + typeof typedObj["page_id"] === "string") && + (typedObj["course_id"] === null || + typeof typedObj["course_id"] === "string") && + (typedObj["locale"] === null || + typeof typedObj["locale"] === "string") + ) +} + +export function isParagraphSuggestionRequest(obj: unknown): obj is ParagraphSuggestionRequest { + const typedObj = obj as ParagraphSuggestionRequest + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + typeof typedObj["action"] === "string" && + typeof typedObj["content"] === "string" && + typeof typedObj["is_html"] === "boolean" && + (typedObj["meta"] === null || + isParagraphSuggestionMeta(typedObj["meta"]) as boolean) && + (typedObj["context"] === null || + isParagraphSuggestionContext(typedObj["context"]) as boolean) + ) +} + +export function isParagraphSuggestionResponse(obj: unknown): obj is ParagraphSuggestionResponse { + const typedObj = obj as ParagraphSuggestionResponse + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + Array.isArray(typedObj["suggestions"]) && + typedObj["suggestions"].every((e: any) => + typeof e === "string" + ) + ) +} + export function isHealthStatus(obj: unknown): obj is HealthStatus { const typedObj = obj as HealthStatus return ( diff --git a/shared-module/packages/common/src/bindings.ts b/shared-module/packages/common/src/bindings.ts index db0d9b3f1b5..ebe34175ba7 100644 --- a/shared-module/packages/common/src/bindings.ts +++ b/shared-module/packages/common/src/bindings.ts @@ -2701,6 +2701,30 @@ export interface ServicePortInfo { protocol: string | null } +export interface ParagraphSuggestionMeta { + tone: string | null + language: string | null + setting_type: string | null +} + +export interface ParagraphSuggestionContext { + page_id: string | null + course_id: string | null + locale: string | null +} + +export interface ParagraphSuggestionRequest { + action: string + content: string + is_html: boolean + meta: ParagraphSuggestionMeta | null + context: ParagraphSuggestionContext | null +} + +export interface ParagraphSuggestionResponse { + suggestions: Array +} + export type HealthStatus = "healthy" | "warning" | "error" export interface SystemHealthStatus { diff --git a/shared-module/packages/common/src/locales/ar/cms.json b/shared-module/packages/common/src/locales/ar/cms.json index 5a699d41b49..0caee480d61 100644 --- a/shared-module/packages/common/src/locales/ar/cms.json +++ b/shared-module/packages/common/src/locales/ar/cms.json @@ -4,6 +4,59 @@ "add-self-review": "إضافة مراجعة ذاتية", "add-slide": "إضافة شريحة", "add-task": "إضافة مهمة", + "ai-action-failed": "فشل الإجراء.", + "ai-back-to-main-menu": "العودة إلى قائمة الذكاء الاصطناعي", + "ai-citations-add-source-needed": "إضافة علامات \"المصدر مطلوب\"", + "ai-citations-avoid-plagiarism": "إعادة الصياغة لتجنب الانتحال", + "ai-citations-suggest-citation": "اقتراح أماكن تحتاج إلى مرجع", + "ai-dialog-title-apply": "تطبيق التغييرات على الفقرة", + "ai-generate-add-concluding-sentence": "إضافة جملة ختامية", + "ai-generate-add-counterpoint": "إضافة وجهة نظر معاكسة", + "ai-generate-add-example": "إضافة مثال", + "ai-generate-continue-paragraph": "متابعة هذه الفقرة", + "ai-generate-draft-from-notes": "إنشاء فقرة من الملاحظات…", + "ai-group-citations-integrity": "المراجع والنزاهة", + "ai-group-generate": "إنشاء", + "ai-group-improve": "تحسين", + "ai-group-learning-support": "دعم التعلم", + "ai-group-settings": "الإعدادات", + "ai-group-structure": "البنية", + "ai-group-summaries": "الملخصات", + "ai-improve-academic-style": "تعزيز الأسلوب الأكاديمي", + "ai-improve-clarity": "تحسين الوضوح (مع الحفاظ على المعنى)", + "ai-improve-concise": "جعل الفقرة أكثر إيجازًا", + "ai-improve-expand-detail": "التوسيع بمزيد من التفاصيل", + "ai-improve-fix-spelling-grammar": "تصحيح الإملاء والقواعد", + "ai-improve-flow": "تحسين السلاسة والانتقالات", + "ai-learning-add-analogy": "إضافة تشبيه أو مثال مشابه", + "ai-learning-add-check-understanding": "إضافة فحص سريع للفهم", + "ai-learning-add-definitions": "إضافة تعريفات للمصطلحات الأساسية", + "ai-learning-add-practice-question": "إضافة سؤال تدريبي قصير", + "ai-learning-simplify-beginners": "تبسيط للمبتدئين", + "ai-settings-remember-tone": "تذكّر النبرة المفضلة (لهذا المستند)", + "ai-settings-set-audience": "تحديد الجمهور المستهدف…", + "ai-settings-set-reading-level": "تحديد مستوى القراءة…", + "ai-structure-combine-into-one": "دمج في فقرة واحدة", + "ai-structure-create-topic-sentence": "إنشاء جملة موضوعية", + "ai-structure-from-bullets": "تحويل النقاط إلى فقرة", + "ai-structure-reorder-sentences": "إعادة ترتيب الجمل بشكل منطقي", + "ai-structure-split-into-paragraphs": "تقسيم إلى فقرات أصغر", + "ai-structure-to-bullets": "تحويل إلى نقاط تعداد", + "ai-submenu-tone-voice": "النبرة والصوت", + "ai-submenu-translate": "الترجمة", + "ai-summaries-key-takeaway": "أهم فكرة للطلاب", + "ai-summaries-one-sentence": "ملخص في جملة واحدة", + "ai-summaries-two-three-sentences": "ملخص في 2–3 جمل", + "ai-tone-academic-formal": "أكاديمي / رسمي", + "ai-tone-confident": "واثق", + "ai-tone-encouraging-supportive": "مشجع / داعم", + "ai-tone-friendly-conversational": "ودود / حواري", + "ai-tone-neutral-objective": "محايد / موضوعي", + "ai-tone-serious": "جاد", + "ai-toolbar-label": "مساعد الكتابة بالذكاء الاصطناعي", + "ai-translate-english": "الإنجليزية", + "ai-translate-finnish": "الفنلندية", + "ai-translate-swedish": "السويدية", "answer-required": "الإجابة مطلوبة", "are-you-sure-you-want-to-discard-changes": "هل أنت متأكد أنك تريد تجاهل التغييرات غير المحفوظة؟", "authors-block": "كتلة المؤلفين", diff --git a/shared-module/packages/common/src/locales/en/cms.json b/shared-module/packages/common/src/locales/en/cms.json index 6a3ef7cb866..c3077c70078 100644 --- a/shared-module/packages/common/src/locales/en/cms.json +++ b/shared-module/packages/common/src/locales/en/cms.json @@ -4,6 +4,59 @@ "add-self-review": "Add self review", "add-slide": "Add slide", "add-task": "Add task", + "ai-action-failed": "AI action failed.", + "ai-back-to-main-menu": "Back to AI menu", + "ai-citations-add-source-needed": "Add “source needed” markers", + "ai-citations-avoid-plagiarism": "Rewrite to avoid plagiarism", + "ai-citations-suggest-citation": "Suggest where a citation is needed", + "ai-dialog-title-apply": "Apply changes to paragraph", + "ai-generate-add-concluding-sentence": "Add a concluding sentence", + "ai-generate-add-counterpoint": "Add a counterpoint", + "ai-generate-add-example": "Add an example", + "ai-generate-continue-paragraph": "Continue this paragraph", + "ai-generate-draft-from-notes": "Draft paragraph from notes…", + "ai-group-citations-integrity": "Citations & integrity", + "ai-group-generate": "Generate", + "ai-group-improve": "Improve", + "ai-group-learning-support": "Learning support", + "ai-group-settings": "Settings", + "ai-group-structure": "Structure", + "ai-group-summaries": "Summaries", + "ai-improve-academic-style": "Strengthen academic style", + "ai-improve-clarity": "Improve clarity (keep meaning)", + "ai-improve-concise": "Make more concise", + "ai-improve-expand-detail": "Expand with detail", + "ai-improve-fix-spelling-grammar": "Fix spelling & grammar", + "ai-improve-flow": "Improve flow & transitions", + "ai-learning-add-analogy": "Add an analogy", + "ai-learning-add-check-understanding": "Add a quick check-for-understanding", + "ai-learning-add-definitions": "Add definitions for key terms", + "ai-learning-add-practice-question": "Add a short practice question", + "ai-learning-simplify-beginners": "Simplify for beginners", + "ai-settings-remember-tone": "Remember preferred tone (this doc)", + "ai-settings-set-audience": "Set audience…", + "ai-settings-set-reading-level": "Set reading level…", + "ai-structure-combine-into-one": "Combine into one paragraph", + "ai-structure-create-topic-sentence": "Create topic sentence", + "ai-structure-from-bullets": "Turn bullets into paragraph", + "ai-structure-reorder-sentences": "Reorder sentences for logic", + "ai-structure-split-into-paragraphs": "Split into smaller paragraphs", + "ai-structure-to-bullets": "Turn into bullet points", + "ai-submenu-tone-voice": "Tone & Voice", + "ai-submenu-translate": "Translate", + "ai-summaries-key-takeaway": "Key takeaway for students", + "ai-summaries-one-sentence": "One-sentence summary", + "ai-summaries-two-three-sentences": "2–3 sentence summary", + "ai-tone-academic-formal": "Academic / Formal", + "ai-tone-confident": "Confident", + "ai-tone-encouraging-supportive": "Encouraging / Supportive", + "ai-tone-friendly-conversational": "Friendly / Conversational", + "ai-tone-neutral-objective": "Neutral / Objective", + "ai-tone-serious": "Serious", + "ai-toolbar-label": "AI writing assistant", + "ai-translate-english": "English", + "ai-translate-finnish": "Finnish", + "ai-translate-swedish": "Swedish", "answer-required": "Answer required", "are-you-sure-you-want-to-discard-changes": "Are you sure you want to discard unsaved changes?", "authors-block": "Authors Block", diff --git a/shared-module/packages/common/src/locales/fi/cms.json b/shared-module/packages/common/src/locales/fi/cms.json index 4c9d09c10a4..7830eb48096 100644 --- a/shared-module/packages/common/src/locales/fi/cms.json +++ b/shared-module/packages/common/src/locales/fi/cms.json @@ -4,6 +4,59 @@ "add-self-review": "Lisää itsearviointi", "add-slide": "Lisää dia", "add-task": "Lisää tehtävä", + "ai-action-failed": "Tehtävä epäonnistui.", + "ai-back-to-main-menu": "Takaisin tekoälyvalikkoon", + "ai-citations-add-source-needed": "Lisää \"lähde tarvitaan\" -merkinnät", + "ai-citations-avoid-plagiarism": "Kirjoita uudelleen plagioinnin välttämiseksi", + "ai-citations-suggest-citation": "Ehdota, mihin tarvitaan lähdeviite", + "ai-dialog-title-apply": "Ota muutokset käyttöön kappaleessa", + "ai-generate-add-concluding-sentence": "Lisää päätöslause", + "ai-generate-add-counterpoint": "Lisää vasta-argumentti", + "ai-generate-add-example": "Lisää esimerkki", + "ai-generate-continue-paragraph": "Jatka tätä kappaletta", + "ai-generate-draft-from-notes": "Luo kappale muistiinpanoista…", + "ai-group-citations-integrity": "Lähteet ja eettisyys", + "ai-group-generate": "Luo", + "ai-group-improve": "Paranna", + "ai-group-learning-support": "Oppimisen tuki", + "ai-group-settings": "Asetukset", + "ai-group-structure": "Rakenne", + "ai-group-summaries": "Yhteenvedot", + "ai-improve-academic-style": "Vahvista akateemista tyyliä", + "ai-improve-clarity": "Paranna selkeyttä (säilytä merkitys)", + "ai-improve-concise": "Tiivistä tekstiä", + "ai-improve-expand-detail": "Laajenna yksityiskohtia", + "ai-improve-fix-spelling-grammar": "Korjaa oikeinkirjoitus ja kielioppi", + "ai-improve-flow": "Paranna sujuvuutta ja siirtymiä", + "ai-learning-add-analogy": "Lisää vertauskuva tai analogia", + "ai-learning-add-check-understanding": "Lisää nopea ymmärryksen tarkistus", + "ai-learning-add-definitions": "Lisää keskeisten käsitteiden määritelmät", + "ai-learning-add-practice-question": "Lisää lyhyt harjoituskysymys", + "ai-learning-simplify-beginners": "Yksinkertaista aloitteleville", + "ai-settings-remember-tone": "Muista suosittu sävy (tämä asiakirja)", + "ai-settings-set-audience": "Aseta kohderyhmä…", + "ai-settings-set-reading-level": "Aseta luettavuustaso…", + "ai-structure-combine-into-one": "Yhdistä yhdeksi kappaleeksi", + "ai-structure-create-topic-sentence": "Luo kappaleen pääajatus", + "ai-structure-from-bullets": "Muuta luettelopisteet kappaleeksi", + "ai-structure-reorder-sentences": "Järjestä lauseet loogiseen järjestykseen", + "ai-structure-split-into-paragraphs": "Jaa pienemmiksi kappaleiksi", + "ai-structure-to-bullets": "Muuta luettelopisteiksi", + "ai-submenu-tone-voice": "Tyyli ja sävy", + "ai-submenu-translate": "Käännös", + "ai-summaries-key-takeaway": "Tärkein oppi opiskelijalle", + "ai-summaries-one-sentence": "Yhden lauseen yhteenveto", + "ai-summaries-two-three-sentences": "2–3 lauseen yhteenveto", + "ai-tone-academic-formal": "Akateeminen / muodollinen", + "ai-tone-confident": "Itsevarma", + "ai-tone-encouraging-supportive": "Kannustava / tukeva", + "ai-tone-friendly-conversational": "Ystävällinen / keskusteleva", + "ai-tone-neutral-objective": "Neutraali / objektiivinen", + "ai-tone-serious": "Vakava", + "ai-toolbar-label": "Tekstin tekoälyavustaja", + "ai-translate-english": "englanti", + "ai-translate-finnish": "suomi", + "ai-translate-swedish": "ruotsi", "answer-required": "Pakollinen vastaus", "are-you-sure-you-want-to-discard-changes": "Haluatko varmasti hylätä tallentamattomat muutokset?", "authors-block": "Kirjoittajat lohko", diff --git a/shared-module/packages/common/src/locales/sv/cms.json b/shared-module/packages/common/src/locales/sv/cms.json index f3d13136e14..c447d3399fa 100644 --- a/shared-module/packages/common/src/locales/sv/cms.json +++ b/shared-module/packages/common/src/locales/sv/cms.json @@ -4,6 +4,59 @@ "add-self-review": "Lägg till självbedömning", "add-slide": "Lägg till bild", "add-task": "Lägg till uppgift", + "ai-action-failed": "AI-åtgärden misslyckades.", + "ai-back-to-main-menu": "Tillbaka till AI-menyn", + "ai-citations-add-source-needed": "Lägg till \"källa behövs\"-markeringar", + "ai-citations-avoid-plagiarism": "Skriv om för att undvika plagiering", + "ai-citations-suggest-citation": "Föreslå var källa behövs", + "ai-dialog-title-apply": "Verkställ ändringar i stycket", + "ai-generate-add-concluding-sentence": "Lägg till avslutande mening", + "ai-generate-add-counterpoint": "Lägg till motargument", + "ai-generate-add-example": "Lägg till ett exempel", + "ai-generate-continue-paragraph": "Fortsätt detta stycke", + "ai-generate-draft-from-notes": "Skapa stycke från anteckningar…", + "ai-group-citations-integrity": "Källor och integritet", + "ai-group-generate": "Generera", + "ai-group-improve": "Förbättra", + "ai-group-learning-support": "Stöd för lärande", + "ai-group-settings": "Inställningar", + "ai-group-structure": "Struktur", + "ai-group-summaries": "Sammanfattningar", + "ai-improve-academic-style": "Stärk akademisk stil", + "ai-improve-clarity": "Förbättra tydlighet (behåll betydelsen)", + "ai-improve-concise": "Gör texten mer koncis", + "ai-improve-expand-detail": "Utöka med fler detaljer", + "ai-improve-fix-spelling-grammar": "Rätta stavning och grammatik", + "ai-improve-flow": "Förbättra flyt och övergångar", + "ai-learning-add-analogy": "Lägg till en analogi", + "ai-learning-add-check-understanding": "Lägg till en snabb förståelsekontroll", + "ai-learning-add-definitions": "Lägg till definitioner för nyckelbegrepp", + "ai-learning-add-practice-question": "Lägg till en kort övningsfråga", + "ai-learning-simplify-beginners": "Förenkla för nybörjare", + "ai-settings-remember-tone": "Kom ihåg föredragen ton (detta dokument)", + "ai-settings-set-audience": "Ställ in målgrupp…", + "ai-settings-set-reading-level": "Ställ in läsnivå…", + "ai-structure-combine-into-one": "Slå ihop till ett stycke", + "ai-structure-create-topic-sentence": "Skapa ämnesmening", + "ai-structure-from-bullets": "Gör om punktlista till stycke", + "ai-structure-reorder-sentences": "Ordna meningar logiskt", + "ai-structure-split-into-paragraphs": "Dela upp i mindre stycken", + "ai-structure-to-bullets": "Gör om till punktlista", + "ai-submenu-tone-voice": "Ton och röst", + "ai-submenu-translate": "Översätt", + "ai-summaries-key-takeaway": "Viktigaste lärdom för studenter", + "ai-summaries-one-sentence": "Sammanfatta i en mening", + "ai-summaries-two-three-sentences": "Sammanfatta i 2–3 meningar", + "ai-tone-academic-formal": "Akademisk / formell", + "ai-tone-confident": "Självsäker", + "ai-tone-encouraging-supportive": "Uppmuntrande / stöttande", + "ai-tone-friendly-conversational": "Vänlig / konverserande", + "ai-tone-neutral-objective": "Neutral / objektiv", + "ai-tone-serious": "Allvarlig", + "ai-toolbar-label": "AI-skrivassistent", + "ai-translate-english": "engelska", + "ai-translate-finnish": "finska", + "ai-translate-swedish": "svenska", "answer-required": "Svar krävs", "are-you-sure-you-want-to-discard-changes": "Är du säker på att du vill kassera osparade ändringar?", "authors-block": "Författarblock", diff --git a/shared-module/packages/common/src/locales/uk/cms.json b/shared-module/packages/common/src/locales/uk/cms.json index 3f6dd229030..6bb439b5fa4 100644 --- a/shared-module/packages/common/src/locales/uk/cms.json +++ b/shared-module/packages/common/src/locales/uk/cms.json @@ -4,6 +4,59 @@ "add-self-review": "Додати самооцінку", "add-slide": "Додати слайд", "add-task": "Додати завдання", + "ai-action-failed": "Дія не виконана.", + "ai-back-to-main-menu": "Назад до меню ШІ", + "ai-citations-add-source-needed": "Додати позначки «потрібне джерело»", + "ai-citations-avoid-plagiarism": "Переписати, щоб уникнути плагіату", + "ai-citations-suggest-citation": "Запропонувати, де потрібне посилання", + "ai-dialog-title-apply": "Застосувати зміни до абзацу", + "ai-generate-add-concluding-sentence": "Додати завершальне речення", + "ai-generate-add-counterpoint": "Додати контраргумент", + "ai-generate-add-example": "Додати приклад", + "ai-generate-continue-paragraph": "Продовжити цей абзац", + "ai-generate-draft-from-notes": "Створити абзац з нотаток…", + "ai-group-citations-integrity": "Цитування та академічна доброчесність", + "ai-group-generate": "Створити", + "ai-group-improve": "Покращити", + "ai-group-learning-support": "Підтримка навчання", + "ai-group-settings": "Налаштування", + "ai-group-structure": "Структура", + "ai-group-summaries": "Резюме", + "ai-improve-academic-style": "Підсилити академічний стиль", + "ai-improve-clarity": "Покращити зрозумілість (зберегти зміст)", + "ai-improve-concise": "Зробити текст лаконічнішим", + "ai-improve-expand-detail": "Розширити деталями", + "ai-improve-fix-spelling-grammar": "Виправити орфографію та граматику", + "ai-improve-flow": "Покращити зв’язність і переходи", + "ai-learning-add-analogy": "Додати аналогію", + "ai-learning-add-check-understanding": "Додати швидку перевірку розуміння", + "ai-learning-add-definitions": "Додати визначення ключових термінів", + "ai-learning-add-practice-question": "Додати коротке тренувальне запитання", + "ai-learning-simplify-beginners": "Спростити для початківців", + "ai-settings-remember-tone": "Запам’ятати бажаний тон (цей документ)", + "ai-settings-set-audience": "Вказати аудиторію…", + "ai-settings-set-reading-level": "Вказати рівень читання…", + "ai-structure-combine-into-one": "Об’єднати в один абзац", + "ai-structure-create-topic-sentence": "Створити тематичне речення", + "ai-structure-from-bullets": "Перетворити список на абзац", + "ai-structure-reorder-sentences": "Упорядкувати речення логічно", + "ai-structure-split-into-paragraphs": "Розділити на менші абзаци", + "ai-structure-to-bullets": "Перетворити на маркований список", + "ai-submenu-tone-voice": "Тон і стиль", + "ai-submenu-translate": "Переклад", + "ai-summaries-key-takeaway": "Головний висновок для студентів", + "ai-summaries-one-sentence": "Резюме в одному реченні", + "ai-summaries-two-three-sentences": "Резюме в 2–3 реченнях", + "ai-tone-academic-formal": "Академічний / формальний", + "ai-tone-confident": "Впевнений", + "ai-tone-encouraging-supportive": "Заохочувальний / підтримувальний", + "ai-tone-friendly-conversational": "Дружній / розмовний", + "ai-tone-neutral-objective": "Нейтральний / об’єктивний", + "ai-tone-serious": "Серйозний", + "ai-toolbar-label": "Помічник письма (ШІ)", + "ai-translate-english": "англійська", + "ai-translate-finnish": "фінська", + "ai-translate-swedish": "шведська", "answer-required": "Відповідь обов'язкова", "are-you-sure-you-want-to-discard-changes": "Ви впевнені, що хочете відхилити незбережені зміни?", "authors-block": "Блок авторів", diff --git a/system-tests/src/tests/oauth/authorize/code-issuance.spec.ts b/system-tests/src/tests/oauth/authorize/code-issuance.spec.ts index 6a84fbdc9dd..714df137662 100644 --- a/system-tests/src/tests/oauth/authorize/code-issuance.spec.ts +++ b/system-tests/src/tests/oauth/authorize/code-issuance.spec.ts @@ -14,6 +14,7 @@ import { oauthUrl } from "../../../utils/oauth/urlHelpers" test.beforeAll(async () => { await setupRedirectServer() }) + test.afterAll(async () => { await teardownRedirectServer() }) diff --git a/system-tests/src/tests/oauth/discovery.spec.ts b/system-tests/src/tests/oauth/discovery.spec.ts index ffba084f6a6..2957bfcdf25 100644 --- a/system-tests/src/tests/oauth/discovery.spec.ts +++ b/system-tests/src/tests/oauth/discovery.spec.ts @@ -14,6 +14,7 @@ import { setupRedirectServer, teardownRedirectServer } from "../../utils/oauth/r test.beforeAll(async () => { await setupRedirectServer() }) + test.afterAll(async () => { await teardownRedirectServer() }) diff --git a/system-tests/src/tests/oauth/introspect.spec.ts b/system-tests/src/tests/oauth/introspect.spec.ts index 7c421825460..e59815bf38a 100644 --- a/system-tests/src/tests/oauth/introspect.spec.ts +++ b/system-tests/src/tests/oauth/introspect.spec.ts @@ -20,6 +20,7 @@ import { oauthUrl } from "../../utils/oauth/urlHelpers" test.beforeAll(async () => { await setupRedirectServer() }) + test.afterAll(async () => { await teardownRedirectServer() }) diff --git a/system-tests/src/tests/oauth/revocation.spec.ts b/system-tests/src/tests/oauth/revocation.spec.ts index 7778065701a..5d6646accb9 100644 --- a/system-tests/src/tests/oauth/revocation.spec.ts +++ b/system-tests/src/tests/oauth/revocation.spec.ts @@ -21,6 +21,7 @@ import { oauthUrl } from "../../utils/oauth/urlHelpers" test.beforeAll(async () => { await setupRedirectServer() }) + test.afterAll(async () => { await teardownRedirectServer() }) diff --git a/system-tests/src/tests/oauth/token/refresh.spec.ts b/system-tests/src/tests/oauth/token/refresh.spec.ts index ded946dd47b..299bfb0b21e 100644 --- a/system-tests/src/tests/oauth/token/refresh.spec.ts +++ b/system-tests/src/tests/oauth/token/refresh.spec.ts @@ -18,6 +18,7 @@ import { oauthUrl } from "../../../utils/oauth/urlHelpers" test.beforeAll(async () => { await setupRedirectServer() }) + test.afterAll(async () => { await teardownRedirectServer() }) diff --git a/system-tests/src/tests/oauth/userinfo/bearer.spec.ts b/system-tests/src/tests/oauth/userinfo/bearer.spec.ts index 407dd5f33ce..f4a71a1a16f 100644 --- a/system-tests/src/tests/oauth/userinfo/bearer.spec.ts +++ b/system-tests/src/tests/oauth/userinfo/bearer.spec.ts @@ -13,6 +13,7 @@ import { oauthUrl } from "../../../utils/oauth/urlHelpers" test.beforeAll(async () => { await setupRedirectServer() }) + test.afterAll(async () => { await teardownRedirectServer() }) diff --git a/system-tests/src/tests/oauth/userinfo/dpop.spec.ts b/system-tests/src/tests/oauth/userinfo/dpop.spec.ts index 5c043666500..527117bc2bd 100644 --- a/system-tests/src/tests/oauth/userinfo/dpop.spec.ts +++ b/system-tests/src/tests/oauth/userinfo/dpop.spec.ts @@ -13,6 +13,7 @@ import { oauthUrl } from "../../../utils/oauth/urlHelpers" test.beforeAll(async () => { await setupRedirectServer() }) + test.afterAll(async () => { await teardownRedirectServer() }) diff --git a/system-tests/src/tests/oauth/userinfo/scopes.spec.ts b/system-tests/src/tests/oauth/userinfo/scopes.spec.ts index 9a7aca5a66e..ed2f2121a8c 100644 --- a/system-tests/src/tests/oauth/userinfo/scopes.spec.ts +++ b/system-tests/src/tests/oauth/userinfo/scopes.spec.ts @@ -12,6 +12,7 @@ import { oauthUrl } from "../../../utils/oauth/urlHelpers" test.beforeAll(async () => { await setupRedirectServer() }) + test.afterAll(async () => { await teardownRedirectServer() })