From 57904a56a04ee35e62890705c869a82765655572 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 4 Dec 2025 15:28:12 +0530 Subject: [PATCH 01/11] chore: update vulnerable packages (#12996) Co-authored-by: Muhsin Keloth --- .nvmrc | 2 +- package.json | 8 +- pnpm-lock.yaml | 826 +++++++++++++++++++++++++------------------------ 3 files changed, 425 insertions(+), 411 deletions(-) diff --git a/.nvmrc b/.nvmrc index 6f7af3750..b88575e38 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.5.1 \ No newline at end of file +23.7.0 \ No newline at end of file diff --git a/package.json b/package.json index eb12fcf61..406e83b13 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@rails/actioncable": "6.1.3", "@rails/ujs": "^7.1.400", "@scmmishra/pico-search": "0.5.4", - "@sentry/vue": "^8.31.0", + "@sentry/vue": "^8.55.0", "@sindresorhus/slugify": "2.2.1", "@tailwindcss/typography": "^0.5.15", "@tanstack/vue-table": "^8.20.5", @@ -56,7 +56,7 @@ "@vueuse/components": "^12.0.0", "@vueuse/core": "^12.0.0", "activestorage": "^5.2.6", - "axios": "^1.8.2", + "axios": "^1.13.2", "camelcase-keys": "^9.1.3", "chart.js": "~4.4.4", "color2k": "^2.0.2", @@ -132,8 +132,8 @@ "fake-indexeddb": "^6.0.0", "histoire": "0.17.15", "husky": "^7.0.0", - "jsdom": "^24.1.3", - "lint-staged": "14.0.1", + "jsdom": "^27.2.0", + "lint-staged": "^16.2.7", "postcss": "^8.4.47", "postcss-preset-env": "^8.5.1", "prettier": "^3.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 645653706..65dacb287 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,8 +56,8 @@ importers: specifier: 0.5.4 version: 0.5.4 '@sentry/vue': - specifier: ^8.31.0 - version: 8.31.0(vue@3.5.12(typescript@5.6.2)) + specifier: ^8.55.0 + version: 8.55.0(pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)))(vue@3.5.12(typescript@5.6.2)) '@sindresorhus/slugify': specifier: 2.2.1 version: 2.2.1 @@ -89,8 +89,8 @@ importers: specifier: ^5.2.6 version: 5.2.8 axios: - specifier: ^1.8.2 - version: 1.8.2 + specifier: ^1.13.2 + version: 1.13.2 camelcase-keys: specifier: ^9.1.3 version: 9.1.3 @@ -268,7 +268,7 @@ importers: version: 8.2.6(size-limit@8.2.6) '@vitest/coverage-v8': specifier: 3.0.5 - version: 3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0)) + version: 3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0)) '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 @@ -312,11 +312,11 @@ importers: specifier: ^7.0.0 version: 7.0.4 jsdom: - specifier: ^24.1.3 - version: 24.1.3 + specifier: ^27.2.0 + version: 27.2.0 lint-staged: - specifier: 14.0.1 - version: 14.0.1(enquirer@2.4.1) + specifier: ^16.2.7 + version: 16.2.7 postcss: specifier: ^8.4.47 version: 8.4.47 @@ -343,7 +343,7 @@ importers: version: 5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) vitest: specifier: 3.0.5 - version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0) + version: 3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0) packages: @@ -351,6 +351,9 @@ packages: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} + '@acemir/cssom@0.9.24': + resolution: {integrity: sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==} + '@akryum/tinypool@0.3.1': resolution: {integrity: sha512-nznEC1ZA/m3hQDEnrGQ4c5gkaa9pcaVnw4LFJyzBAaR7E3nfiAPEHS3otnSafpZouVnoKeITl5D+2LsnwlnK8g==} engines: {node: '>=14.0.0'} @@ -369,6 +372,15 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@asamuzakjp/css-color@4.1.0': + resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==} + + '@asamuzakjp/dom-selector@6.7.5': + resolution: {integrity: sha512-Eks6dY8zau4m4wNRQjRVaKQRTalNcPcBvU1ZQ35w5kKRk1gUeNCkVLsRiATurjASTp3TKM4H10wsI50nx3NZdw==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/helper-string-parser@7.25.9': resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} engines: {node: '>=6.9.0'} @@ -448,6 +460,10 @@ packages: resolution: {integrity: sha512-OWkqBa7PDzZuJ3Ha7T5bxdSVfSCfTq6K1mbAhbO1MD+GSULGjrp45i5RudyJOedstSarN/3mdwu9upJE7gDXfw==} engines: {node: ^14 || ^16 || >=18} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + '@csstools/css-calc@1.1.1': resolution: {integrity: sha512-Nh+iLCtjlooTzuR0lpmB8I6hPX/VupcGQ3Z1U2+wgJJ4fa8+cWkub+lCsbZcYPzBGsZLEL8fQAg+Na5dwEFJxg==} engines: {node: ^14 || ^16 || >=18} @@ -455,6 +471,13 @@ packages: '@csstools/css-parser-algorithms': ^2.1.1 '@csstools/css-tokenizer': ^2.1.1 + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-color-parser@1.2.1': resolution: {integrity: sha512-NcmaoJIEycIH0HnzZRrwRcBljPh1AWcXl4CNL8MAD3+Zy8XyIpdTtTMaY/phnLHHIYkyjaoSTdxAecss6+PCcg==} engines: {node: ^14 || ^16 || >=18} @@ -462,16 +485,37 @@ packages: '@csstools/css-parser-algorithms': ^2.1.1 '@csstools/css-tokenizer': ^2.1.1 + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms@2.2.0': resolution: {integrity: sha512-9BoQ/jSrPq4vv3b9jjLW+PNNv56KlDH5JMx5yASSNrCtvq70FCNZUjXRvbCeR9hYj9ZyhURtqpU/RFIgg6kiOw==} engines: {node: ^14 || ^16 || >=18} peerDependencies: '@csstools/css-tokenizer': ^2.1.1 + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': + resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + engines: {node: '>=18'} + '@csstools/css-tokenizer@2.1.1': resolution: {integrity: sha512-GbrTj2Z8MCTUv+52GE0RbFGM527xuXZ0Xa5g0Z+YN573uveS4G0qi6WNOMyz3yrFM/jaILTTwJ0+umx81EzqfA==} engines: {node: ^14 || ^16 || >=18} + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@csstools/media-query-list-parser@2.1.1': resolution: {integrity: sha512-pUjtFbaKbiFNjJo8pprrIaXLvQvWIlwPiFnRI4sEnc4F0NIGTOsw8kaJSR3CmZAKEvV8QYckovgAnWQC0bgLLQ==} engines: {node: ^14 || ^16 || >=18} @@ -1166,43 +1210,39 @@ packages: '@scmmishra/pico-search@0.5.4': resolution: {integrity: sha512-JdV8KumQ+pE5tqgQ71xUT9biE/qV//tx3NCqTLkW9Z4tsjKGN0B6kVowmtaZBAtErqir9XiMxsKXRTMF/MpUww==} - '@sentry-internal/browser-utils@8.31.0': - resolution: {integrity: sha512-Bq7TFMhPr1PixRGYkB/6ar9ws7sj224XzQ+hgpz6OxGEc9fQakvD8t/Nn7dp14k3FI/hcBRA6BBvpOKUUuPgGA==} + '@sentry-internal/browser-utils@8.55.0': + resolution: {integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==} engines: {node: '>=14.18'} - '@sentry-internal/feedback@8.31.0': - resolution: {integrity: sha512-R3LcC2IaTe8lgi5AU9h0rMgyVPpaTiMSLRhRlVeQPVmAKCz8pSG/um13q37t0BsXpTaImW9yYQ71Aj6h6IrShQ==} + '@sentry-internal/feedback@8.55.0': + resolution: {integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==} engines: {node: '>=14.18'} - '@sentry-internal/replay-canvas@8.31.0': - resolution: {integrity: sha512-ConyrhWozx4HluRj0+9teN4XTC1ndXjxMdJQvDnbLFsQhCCEdwUfaZVshV1CFe9T08Bfyjruaw33yR7pDXYktw==} + '@sentry-internal/replay-canvas@8.55.0': + resolution: {integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==} engines: {node: '>=14.18'} - '@sentry-internal/replay@8.31.0': - resolution: {integrity: sha512-r8hmFDwWxeAxpdzBCRWTKQ/QHl8QanFw8XfM0fvFes/H1d/b43Vwc/IiUnsYoMOdooIP8hJFGDKlfq+Y5uVVGA==} + '@sentry-internal/replay@8.55.0': + resolution: {integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==} engines: {node: '>=14.18'} - '@sentry/browser@8.31.0': - resolution: {integrity: sha512-LZK0uLPGB4Al+qWc1eaad+H/1SR6CY9a0V2XWpUbNAT3+VkEo0Z/78bW1kb43N0cok87hNPOe+c66SfwdxphVQ==} + '@sentry/browser@8.55.0': + resolution: {integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==} engines: {node: '>=14.18'} - '@sentry/core@8.31.0': - resolution: {integrity: sha512-5zsMBOML18e5a/ZoR5XpcYF59e2kSxb6lTg13u52f/+NA27EPgxKgXim5dz6L/6+0cizgwwmFaZFGJiFc2qoAA==} + '@sentry/core@8.55.0': + resolution: {integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==} engines: {node: '>=14.18'} - '@sentry/types@8.31.0': - resolution: {integrity: sha512-prRM/n5nlP+xQZSpdEkSR8BwwZtgsLk0NbI8eCjTMu2isVlrlggop8pVaJb7y9HmElVtDA1Q6y4u8TD2htQKFQ==} - engines: {node: '>=14.18'} - - '@sentry/utils@8.31.0': - resolution: {integrity: sha512-9W2LZ9QIHKc0HSyH/7UmTolc01Q4vX/qMSZk7i1noinlkQtnRUmTP39r1DSITjKCrDHj6zvB/J1RPDUoRcTXxQ==} - engines: {node: '>=14.18'} - - '@sentry/vue@8.31.0': - resolution: {integrity: sha512-w512J2XLs43OZ7KBcdy4ho+IWMf37TQDJ5+JBONC+OLmGo7rixAZZxwIA7nI1/kZsBYEZ6JZL1uPCMrwwe/BsQ==} + '@sentry/vue@8.55.0': + resolution: {integrity: sha512-J6lcpzL39snV/spoGpwyk5Rp1wSFxOV4qV1NhQ9OlLHORVBp/Xpw7cEA0oKqG2w1wVtCV+gC5Jjf9HTmYiHQOQ==} engines: {node: '>=14.18'} peerDependencies: + pinia: 2.x || 3.x vue: 2.x || 3.x + peerDependenciesMeta: + pinia: + optional: true '@sindresorhus/slugify@2.2.1': resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==} @@ -1508,8 +1548,8 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} - agent-base@7.1.1: - resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} ajv@6.12.6: @@ -1529,6 +1569,10 @@ packages: resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} engines: {node: '>=12'} + ansi-escapes@7.2.0: + resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1622,12 +1666,15 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.8.2: - resolution: {integrity: sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -1758,10 +1805,6 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -1770,9 +1813,9 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-truncate@3.1.0: - resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -1805,9 +1848,9 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} - commander@11.0.0: - resolution: {integrity: sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==} - engines: {node: '>=16'} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1881,6 +1924,10 @@ packages: peerDependencies: postcss: ^8.4 + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssdb@7.6.0: resolution: {integrity: sha512-Nna7rph8V0jC6+JBY4Vk4ndErUmfJfV6NJCaZdurL0omggabiy+QB2HCQtu5c/ACLZ0I7REv7A4QyPIoYzZx0w==} @@ -1899,9 +1946,9 @@ packages: resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} engines: {node: '>=8'} - cssstyle@4.0.1: - resolution: {integrity: sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==} - engines: {node: '>=18'} + cssstyle@5.3.3: + resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} + engines: {node: '>=20'} csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -1910,9 +1957,9 @@ packages: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} + engines: {node: '>=20'} data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} @@ -1958,15 +2005,6 @@ packages: supports-color: optional: true - debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.5: resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} engines: {node: '>=6.0'} @@ -1985,6 +2023,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -1992,6 +2039,9 @@ packages: decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2122,6 +2172,14 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-abstract@1.22.2: resolution: {integrity: sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==} engines: {node: '>= 0.4'} @@ -2130,10 +2188,6 @@ packages: resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} engines: {node: '>= 0.4'} - es-define-property@1.0.0: - resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} - engines: {node: '>= 0.4'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2332,10 +2386,6 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - execa@7.2.0: - resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} - engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} - expect-type@1.1.0: resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} engines: {node: '>=12.0.0'} @@ -2423,8 +2473,8 @@ packages: '@nuxt/kit': optional: true - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2439,12 +2489,8 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} - form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} - engines: {node: '>= 6'} - - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} fraction.js@4.3.7: @@ -2480,6 +2526,10 @@ packages: resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} engines: {node: '>=18'} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} @@ -2492,10 +2542,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - get-symbol-description@1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} engines: {node: '>= 0.4'} @@ -2555,9 +2601,6 @@ packages: resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - gopd@1.0.1: - resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2658,14 +2701,10 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - https-proxy-agent@7.0.5: - resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - human-signals@4.3.1: - resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} - engines: {node: '>=14.18.0'} - husky@7.0.4: resolution: {integrity: sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==} engines: {node: '>=12'} @@ -2771,9 +2810,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-fullwidth-code-point@4.0.0: - resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} - engines: {node: '>=12'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} is-function@1.0.2: resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==} @@ -2831,10 +2870,6 @@ packages: resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} engines: {node: '>= 0.4'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -2921,11 +2956,11 @@ packages: canvas: optional: true - jsdom@24.1.3: - resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==} - engines: {node: '>=18'} + jsdom@27.2.0: + resolution: {integrity: sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - canvas: ^2.11.2 + canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true @@ -3007,19 +3042,14 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - lint-staged@14.0.1: - resolution: {integrity: sha512-Mw0cL6HXnHN1ag0mN/Dg4g6sr8uf8sn98w2Oc1ECtFto9tvRF7nkXGJRbx8gPlHyoR0pLyBr2lQHbWwmUHe1Sw==} - engines: {node: ^16.14.0 || >=18.0.0} + lint-staged@16.2.7: + resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} + engines: {node: '>=20.17'} hasBin: true - listr2@6.6.1: - resolution: {integrity: sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg==} - engines: {node: '>=16.0.0'} - peerDependencies: - enquirer: '>= 2.3.0 < 3' - peerDependenciesMeta: - enquirer: - optional: true + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} lit-element@3.3.3: resolution: {integrity: sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==} @@ -3065,9 +3095,9 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} - log-update@5.0.1: - resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} @@ -3078,6 +3108,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -3146,23 +3180,19 @@ packages: md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdurl@1.0.1: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} - engines: {node: '>=8.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3175,14 +3205,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -3250,6 +3272,10 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nano-spawn@2.0.0: + resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} + engines: {node: '>=20.17'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3288,10 +3314,6 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} - npm-run-path@5.1.0: - resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -3348,14 +3370,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -3418,6 +3432,9 @@ packages: parse5@7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3444,10 +3461,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -3895,10 +3908,6 @@ packages: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -3907,9 +3916,6 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.3.0: - resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} - rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -3926,12 +3932,6 @@ packages: rope-sequence@1.3.2: resolution: {integrity: sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg==} - rrweb-cssom@0.6.0: - resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} - - rrweb-cssom@0.7.1: - resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -4029,9 +4029,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -4057,9 +4054,9 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} - slice-ansi@5.0.0: - resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} - engines: {node: '>=12'} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -4122,6 +4119,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + string.prototype.trim@1.2.8: resolution: {integrity: sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==} engines: {node: '>= 0.4'} @@ -4159,10 +4160,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4254,6 +4251,13 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4266,13 +4270,17 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@3.0.0: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} - tr46@5.0.0: - resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} - engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -4617,6 +4625,10 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} + webrtc-adapter@9.0.1: resolution: {integrity: sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==} engines: {node: '>=6.0.0', npm: '>=3.10.0'} @@ -4641,9 +4653,9 @@ packages: resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} engines: {node: '>=12'} - whatwg-url@14.0.0: - resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==} - engines: {node: '>=18'} + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} @@ -4704,6 +4716,18 @@ packages: utf-8-validate: optional: true + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -4729,15 +4753,16 @@ packages: resolution: {integrity: sha512-4wZWvE398hCP7O8n3nXKu/vdq1HcH01ixYlCREaJL5NUMwQ0g3MaGFUBNSlmBtKmhbtVG/Cm6lyYmSVTEVil8A==} engines: {node: ^14.17.0 || >=16.0.0} - yaml@2.3.1: - resolution: {integrity: sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==} - engines: {node: '>= 14'} - yaml@2.5.1: resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} engines: {node: '>= 14'} hasBin: true + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -4766,6 +4791,8 @@ snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} + '@acemir/cssom@0.9.24': {} + '@akryum/tinypool@0.3.1': {} '@alloc/quick-lru@5.2.0': {} @@ -4782,6 +4809,24 @@ snapshots: '@antfu/utils@0.7.10': {} + '@asamuzakjp/css-color@4.1.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.5': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/helper-string-parser@7.25.9': {} '@babel/helper-validator-identifier@7.25.9': {} @@ -4888,11 +4933,18 @@ snapshots: '@csstools/color-helpers@2.1.0': {} + '@csstools/color-helpers@5.1.0': {} + '@csstools/css-calc@1.1.1(@csstools/css-parser-algorithms@2.2.0(@csstools/css-tokenizer@2.1.1))(@csstools/css-tokenizer@2.1.1)': dependencies: '@csstools/css-parser-algorithms': 2.2.0(@csstools/css-tokenizer@2.1.1) '@csstools/css-tokenizer': 2.1.1 + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-color-parser@1.2.1(@csstools/css-parser-algorithms@2.2.0(@csstools/css-tokenizer@2.1.1))(@csstools/css-tokenizer@2.1.1)': dependencies: '@csstools/color-helpers': 2.1.0 @@ -4900,12 +4952,27 @@ snapshots: '@csstools/css-parser-algorithms': 2.2.0(@csstools/css-tokenizer@2.1.1) '@csstools/css-tokenizer': 2.1.1 + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-parser-algorithms@2.2.0(@csstools/css-tokenizer@2.1.1)': dependencies: '@csstools/css-tokenizer': 2.1.1 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': {} + '@csstools/css-tokenizer@2.1.1': {} + '@csstools/css-tokenizer@3.0.4': {} + '@csstools/media-query-list-parser@2.1.1(@csstools/css-parser-algorithms@2.2.0(@csstools/css-tokenizer@2.1.1))(@csstools/css-tokenizer@2.1.1)': dependencies: '@csstools/css-parser-algorithms': 2.2.0(@csstools/css-tokenizer@2.1.1) @@ -5590,60 +5657,41 @@ snapshots: '@scmmishra/pico-search@0.5.4': {} - '@sentry-internal/browser-utils@8.31.0': + '@sentry-internal/browser-utils@8.55.0': dependencies: - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry/core': 8.55.0 - '@sentry-internal/feedback@8.31.0': + '@sentry-internal/feedback@8.55.0': dependencies: - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry/core': 8.55.0 - '@sentry-internal/replay-canvas@8.31.0': + '@sentry-internal/replay-canvas@8.55.0': dependencies: - '@sentry-internal/replay': 8.31.0 - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry-internal/replay': 8.55.0 + '@sentry/core': 8.55.0 - '@sentry-internal/replay@8.31.0': + '@sentry-internal/replay@8.55.0': dependencies: - '@sentry-internal/browser-utils': 8.31.0 - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry-internal/browser-utils': 8.55.0 + '@sentry/core': 8.55.0 - '@sentry/browser@8.31.0': + '@sentry/browser@8.55.0': dependencies: - '@sentry-internal/browser-utils': 8.31.0 - '@sentry-internal/feedback': 8.31.0 - '@sentry-internal/replay': 8.31.0 - '@sentry-internal/replay-canvas': 8.31.0 - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry-internal/browser-utils': 8.55.0 + '@sentry-internal/feedback': 8.55.0 + '@sentry-internal/replay': 8.55.0 + '@sentry-internal/replay-canvas': 8.55.0 + '@sentry/core': 8.55.0 - '@sentry/core@8.31.0': + '@sentry/core@8.55.0': {} + + '@sentry/vue@8.55.0(pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)))(vue@3.5.12(typescript@5.6.2))': dependencies: - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 - - '@sentry/types@8.31.0': {} - - '@sentry/utils@8.31.0': - dependencies: - '@sentry/types': 8.31.0 - - '@sentry/vue@8.31.0(vue@3.5.12(typescript@5.6.2))': - dependencies: - '@sentry/browser': 8.31.0 - '@sentry/core': 8.31.0 - '@sentry/types': 8.31.0 - '@sentry/utils': 8.31.0 + '@sentry/browser': 8.55.0 + '@sentry/core': 8.55.0 vue: 3.5.12(typescript@5.6.2) + optionalDependencies: + pinia: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)) '@sindresorhus/slugify@2.2.1': dependencies: @@ -5739,7 +5787,7 @@ snapshots: vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) vue: 3.5.12(typescript@5.6.2) - '@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))': + '@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -5753,7 +5801,7 @@ snapshots: std-env: 3.8.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0) + vitest: 3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0) transitivePeerDependencies: - supports-color @@ -6048,11 +6096,7 @@ snapshots: transitivePeerDependencies: - supports-color - agent-base@7.1.1: - dependencies: - debug: 4.4.0 - transitivePeerDependencies: - - supports-color + agent-base@7.1.4: {} ajv@6.12.6: dependencies: @@ -6078,6 +6122,10 @@ snapshots: dependencies: type-fest: 1.4.0 + ansi-escapes@7.2.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.0.1: {} @@ -6153,7 +6201,7 @@ snapshots: call-bind: 1.0.2 define-properties: 1.2.0 es-abstract: 1.22.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-array-buffer: 3.0.2 is-shared-array-buffer: 1.0.2 @@ -6190,16 +6238,20 @@ snapshots: dependencies: possible-typed-array-names: 1.0.0 - axios@1.8.2: + axios@1.13.2: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.2 + follow-redirects: 1.15.11 + form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug balanced-match@1.0.2: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.2.0: {} birpc@0.1.1: {} @@ -6261,11 +6313,11 @@ snapshots: call-bind@1.0.2: dependencies: function-bind: 1.1.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 call-bind@1.0.7: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 function-bind: 1.1.2 get-intrinsic: 1.2.4 @@ -6368,20 +6420,16 @@ snapshots: cli-boxes@3.0.0: {} - cli-cursor@4.0.0: - dependencies: - restore-cursor: 4.0.0 - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 cli-spinners@2.9.2: {} - cli-truncate@3.1.0: + cli-truncate@5.1.1: dependencies: - slice-ansi: 5.0.0 - string-width: 5.1.2 + slice-ansi: 7.1.2 + string-width: 8.1.0 cliui@6.0.0: dependencies: @@ -6413,7 +6461,7 @@ snapshots: commander@10.0.1: {} - commander@11.0.0: {} + commander@14.0.2: {} commander@2.20.3: optional: true @@ -6486,6 +6534,11 @@ snapshots: dependencies: postcss: 8.4.47 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + cssdb@7.6.0: {} cssesc@3.0.0: {} @@ -6498,9 +6551,11 @@ snapshots: dependencies: cssom: 0.3.8 - cssstyle@4.0.1: + cssstyle@5.3.3: dependencies: - rrweb-cssom: 0.6.0 + '@asamuzakjp/css-color': 4.1.0 + '@csstools/css-syntax-patches-for-csstree': 1.0.20 + css-tree: 3.1.0 csstype@3.1.3: {} @@ -6510,10 +6565,10 @@ snapshots: whatwg-mimetype: 3.0.0 whatwg-url: 11.0.0 - data-urls@5.0.0: + data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 - whatwg-url: 14.0.0 + whatwg-url: 15.1.0 data-view-buffer@1.0.1: dependencies: @@ -6553,10 +6608,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.4: - dependencies: - ms: 2.1.2 - debug@4.3.5: dependencies: ms: 2.1.2 @@ -6565,19 +6616,25 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize@1.2.0: {} decimal.js@10.4.3: {} + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} define-data-property@1.1.4: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 - gopd: 1.0.1 + gopd: 1.2.0 define-properties@1.2.0: dependencies: @@ -6689,6 +6746,10 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + + environment@1.1.0: {} + es-abstract@1.22.2: dependencies: array-buffer-byte-length: 1.0.0 @@ -6698,14 +6759,14 @@ snapshots: es-set-tostringtag: 2.1.0 es-to-primitive: 1.2.1 function.prototype.name: 1.1.6 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 get-symbol-description: 1.0.0 globalthis: 1.0.3 - gopd: 1.0.1 + gopd: 1.2.0 has: 1.0.3 has-property-descriptors: 1.0.2 has-proto: 1.0.1 - has-symbols: 1.0.3 + has-symbols: 1.1.0 internal-slot: 1.0.5 is-array-buffer: 3.0.2 is-callable: 1.2.7 @@ -6740,7 +6801,7 @@ snapshots: data-view-buffer: 1.0.1 data-view-byte-length: 1.0.1 data-view-byte-offset: 1.0.0 - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 es-object-atoms: 1.0.0 es-set-tostringtag: 2.1.0 @@ -6749,10 +6810,10 @@ snapshots: get-intrinsic: 1.2.4 get-symbol-description: 1.0.2 globalthis: 1.0.4 - gopd: 1.0.1 + gopd: 1.2.0 has-property-descriptors: 1.0.2 has-proto: 1.0.3 - has-symbols: 1.0.3 + has-symbols: 1.1.0 hasown: 2.0.2 internal-slot: 1.0.7 is-array-buffer: 3.0.4 @@ -6780,10 +6841,6 @@ snapshots: unbox-primitive: 1.0.2 which-typed-array: 1.1.15 - es-define-property@1.0.0: - dependencies: - get-intrinsic: 1.2.4 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -7048,18 +7105,6 @@ snapshots: eventemitter3@5.0.1: {} - execa@7.2.0: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 4.3.1 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.1.0 - onetime: 6.0.0 - signal-exit: 3.0.7 - strip-final-newline: 3.0.0 - expect-type@1.1.0: {} extend-shallow@2.0.1: @@ -7150,7 +7195,7 @@ snapshots: vue: 3.5.12(typescript@5.6.2) vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)) - follow-redirects@1.15.9: {} + follow-redirects@1.15.11: {} for-each@0.3.3: dependencies: @@ -7161,17 +7206,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.0: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - - form-data@4.0.2: + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 fraction.js@4.3.7: {} @@ -7202,12 +7242,14 @@ snapshots: get-east-asian-width@1.2.0: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.2.4: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 has-proto: 1.0.3 - has-symbols: 1.0.3 + has-symbols: 1.1.0 hasown: 2.0.2 get-intrinsic@1.3.0: @@ -7228,12 +7270,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stream@6.0.1: {} - get-symbol-description@1.0.0: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 get-symbol-description@1.0.2: dependencies: @@ -7291,7 +7331,7 @@ snapshots: globalthis@1.0.4: dependencies: define-properties: 1.2.1 - gopd: 1.0.1 + gopd: 1.2.0 globby@11.1.0: dependencies: @@ -7310,10 +7350,6 @@ snapshots: merge2: 1.4.1 slash: 4.0.0 - gopd@1.0.1: - dependencies: - get-intrinsic: 1.2.4 - gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -7337,7 +7373,7 @@ snapshots: has-property-descriptors@1.0.2: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 has-proto@1.0.1: {} @@ -7445,8 +7481,8 @@ snapshots: http-proxy-agent@7.0.2: dependencies: - agent-base: 7.1.1 - debug: 4.4.0 + agent-base: 7.1.4 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7457,15 +7493,13 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.5: + https-proxy-agent@7.0.6: dependencies: - agent-base: 7.1.1 - debug: 4.4.0 + agent-base: 7.1.4 + debug: 4.4.3 transitivePeerDependencies: - supports-color - human-signals@4.3.1: {} - husky@7.0.4: {} iconv-lite@0.6.3: @@ -7503,7 +7537,7 @@ snapshots: internal-slot@1.0.5: dependencies: - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 has: 1.0.3 side-channel: 1.0.6 @@ -7516,7 +7550,7 @@ snapshots: is-array-buffer@3.0.2: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-typed-array: 1.1.12 is-array-buffer@3.0.4: @@ -7559,7 +7593,9 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-fullwidth-code-point@4.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.4.0 is-function@1.0.2: {} @@ -7607,15 +7643,13 @@ snapshots: dependencies: call-bind: 1.0.7 - is-stream@3.0.0: {} - is-string@1.0.7: dependencies: has-tostringtag: 1.0.2 is-symbol@1.0.4: dependencies: - has-symbols: 1.0.3 + has-symbols: 1.1.0 is-typed-array@1.1.12: dependencies: @@ -7698,7 +7732,7 @@ snapshots: decimal.js: 10.4.3 domexception: 4.0.0 escodegen: 2.1.0 - form-data: 4.0.2 + form-data: 4.0.5 html-encoding-sniffer: 3.0.0 http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 @@ -7720,28 +7754,27 @@ snapshots: - supports-color - utf-8-validate - jsdom@24.1.3: + jsdom@27.2.0: dependencies: - cssstyle: 4.0.1 - data-urls: 5.0.0 - decimal.js: 10.4.3 - form-data: 4.0.0 + '@acemir/cssom': 0.9.24 + '@asamuzakjp/dom-selector': 6.7.5 + cssstyle: 5.3.3 + data-urls: 6.0.0 + decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 + https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.12 - parse5: 7.1.2 - rrweb-cssom: 0.7.1 + parse5: 8.0.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 4.1.4 + tough-cookie: 6.0.0 w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 + webidl-conversions: 8.0.0 whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 - whatwg-url: 14.0.0 - ws: 8.18.0 + whatwg-url: 15.1.0 + ws: 8.18.3 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -7819,32 +7852,24 @@ snapshots: dependencies: uc.micro: 2.1.0 - lint-staged@14.0.1(enquirer@2.4.1): + lint-staged@16.2.7: dependencies: - chalk: 5.3.0 - commander: 11.0.0 - debug: 4.3.4 - execa: 7.2.0 - lilconfig: 2.1.0 - listr2: 6.6.1(enquirer@2.4.1) - micromatch: 4.0.5 + commander: 14.0.2 + listr2: 9.0.5 + micromatch: 4.0.8 + nano-spawn: 2.0.0 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.3.1 - transitivePeerDependencies: - - enquirer - - supports-color + yaml: 2.8.2 - listr2@6.6.1(enquirer@2.4.1): + listr2@9.0.5: dependencies: - cli-truncate: 3.1.0 + cli-truncate: 5.1.1 colorette: 2.0.20 eventemitter3: 5.0.1 - log-update: 5.0.1 - rfdc: 1.3.0 - wrap-ansi: 8.1.0 - optionalDependencies: - enquirer: 2.4.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 lit-element@3.3.3: dependencies: @@ -7894,13 +7919,13 @@ snapshots: chalk: 5.3.0 is-unicode-supported: 1.3.0 - log-update@5.0.1: + log-update@6.1.0: dependencies: - ansi-escapes: 5.0.0 - cli-cursor: 4.0.0 - slice-ansi: 5.0.0 + ansi-escapes: 7.2.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 strip-ansi: 7.1.0 - wrap-ansi: 8.1.0 + wrap-ansi: 9.0.0 loupe@3.1.3: {} @@ -7910,6 +7935,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.4: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -7990,19 +8017,14 @@ snapshots: crypt: 0.0.2 is-buffer: 1.1.6 + mdn-data@2.12.2: {} + mdurl@1.0.1: {} mdurl@2.0.0: {} - merge-stream@2.0.0: {} - merge2@1.4.1: {} - micromatch@4.0.5: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -8014,10 +8036,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mimic-fn@2.1.0: {} - - mimic-fn@4.0.0: {} - mimic-function@5.0.1: {} min-document@2.19.0: @@ -8081,6 +8099,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nano-spawn@2.0.0: {} + nanoid@3.3.11: {} nanoid@3.3.8: {} @@ -8108,10 +8128,6 @@ snapshots: normalize-range@0.1.2: {} - npm-run-path@5.1.0: - dependencies: - path-key: 4.0.0 - nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -8139,7 +8155,7 @@ snapshots: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 - has-symbols: 1.0.3 + has-symbols: 1.1.0 object-keys: 1.1.1 object.entries@1.1.7: @@ -8175,14 +8191,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -8255,6 +8263,10 @@ snapshots: dependencies: entities: 4.5.0 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} pascal-case@3.1.2: @@ -8275,8 +8287,6 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-parse@1.0.7: {} path-scurry@1.11.1: @@ -8758,11 +8768,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - restore-cursor@4.0.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -8770,8 +8775,6 @@ snapshots: reusify@1.0.4: {} - rfdc@1.3.0: {} - rfdc@1.4.1: {} rimraf@3.0.2: @@ -8808,10 +8811,6 @@ snapshots: rope-sequence@1.3.2: {} - rrweb-cssom@0.6.0: {} - - rrweb-cssom@0.7.1: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -8827,15 +8826,15 @@ snapshots: safe-array-concat@1.0.1: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 - has-symbols: 1.0.3 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 isarray: 2.0.5 safe-array-concat@1.1.2: dependencies: call-bind: 1.0.7 get-intrinsic: 1.2.4 - has-symbols: 1.0.3 + has-symbols: 1.1.0 isarray: 2.0.5 safe-json-parse@4.0.0: @@ -8845,7 +8844,7 @@ snapshots: safe-regex-test@1.0.0: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-regex: 1.1.4 safe-regex-test@1.0.3: @@ -8896,7 +8895,7 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 get-intrinsic: 1.2.4 - gopd: 1.0.1 + gopd: 1.2.0 has-property-descriptors: 1.0.2 set-function-name@2.0.2: @@ -8925,8 +8924,6 @@ snapshots: siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} sirv@2.0.4: @@ -8954,10 +8951,10 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 - slice-ansi@5.0.0: + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.1 - is-fullwidth-code-point: 4.0.0 + is-fullwidth-code-point: 5.1.0 snake-case@3.0.4: dependencies: @@ -9017,6 +9014,11 @@ snapshots: get-east-asian-width: 1.2.0 strip-ansi: 7.1.0 + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.0 + string.prototype.trim@1.2.8: dependencies: call-bind: 1.0.2 @@ -9066,8 +9068,6 @@ snapshots: strip-bom@3.0.0: {} - strip-final-newline@3.0.0: {} - strip-json-comments@3.1.1: {} style-mod@4.1.2: {} @@ -9182,6 +9182,12 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9195,11 +9201,15 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + tr46@3.0.0: dependencies: punycode: 2.3.1 - tr46@5.0.0: + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -9231,7 +9241,7 @@ snapshots: typed-array-buffer@1.0.0: dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-typed-array: 1.1.12 typed-array-buffer@1.0.2: @@ -9251,7 +9261,7 @@ snapshots: dependencies: call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-proto: 1.0.3 is-typed-array: 1.1.13 @@ -9268,7 +9278,7 @@ snapshots: available-typed-arrays: 1.0.7 call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-proto: 1.0.3 is-typed-array: 1.1.13 @@ -9282,7 +9292,7 @@ snapshots: dependencies: call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-proto: 1.0.3 is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 @@ -9300,7 +9310,7 @@ snapshots: dependencies: call-bind: 1.0.7 has-bigints: 1.0.2 - has-symbols: 1.0.3 + has-symbols: 1.1.0 which-boxed-primitive: 1.0.2 undici-types@6.19.8: {} @@ -9419,7 +9429,7 @@ snapshots: sass: 1.79.3 terser: 5.33.0 - vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0): + vitest@3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0): dependencies: '@vitest/expect': 3.0.5 '@vitest/mocker': 3.0.5(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) @@ -9443,7 +9453,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.7.0 - jsdom: 24.1.3 + jsdom: 27.2.0 transitivePeerDependencies: - less - lightningcss @@ -9577,6 +9587,8 @@ snapshots: webidl-conversions@7.0.0: {} + webidl-conversions@8.0.0: {} + webrtc-adapter@9.0.1: dependencies: sdp: 3.2.0 @@ -9598,10 +9610,10 @@ snapshots: tr46: 3.0.0 webidl-conversions: 7.0.0 - whatwg-url@14.0.0: + whatwg-url@15.1.0: dependencies: - tr46: 5.0.0 - webidl-conversions: 7.0.0 + tr46: 6.0.0 + webidl-conversions: 8.0.0 which-boxed-primitive@1.0.2: dependencies: @@ -9618,7 +9630,7 @@ snapshots: available-typed-arrays: 1.0.5 call-bind: 1.0.2 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-tostringtag: 1.0.2 which-typed-array@1.1.15: @@ -9626,7 +9638,7 @@ snapshots: available-typed-arrays: 1.0.7 call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-tostringtag: 1.0.2 which@2.0.2: @@ -9670,6 +9682,8 @@ snapshots: ws@8.18.0: {} + ws@8.18.3: {} + xml-name-validator@4.0.0: {} xml-name-validator@5.0.0: {} @@ -9688,10 +9702,10 @@ snapshots: lodash: 4.17.21 yaml: 2.5.1 - yaml@2.3.1: {} - yaml@2.5.1: {} + yaml@2.8.2: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 From 0a17976913adde20a77a380337ca16a39af7f1e8 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 4 Dec 2025 16:09:04 +0530 Subject: [PATCH 02/11] fix: Filter out unsupported ephemeral message attachments (#13003) Fixes https://linear.app/chatwoot/issue/CW-6070/argumenterror-ephemeral-is-not-a-valid-file-type-argumenterror **Problem** The instagram webhooks containing ephemeral (disappearing) message were causing ArgumentError exceptions because this attachment type is not supported and was not in the enum validation. **Solution** - Added ephemeral to the unsupported_file_type? filter - Ephemeral attachments are now silently filtered out before processing, following the same pattern as existing unsupported types (template, unsupported_type) --- app/builders/messages/messenger/message_builder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 7bc571f6c..8821da9d5 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -101,6 +101,6 @@ class Messages::Messenger::MessageBuilder private def unsupported_file_type?(attachment_type) - [:template, :unsupported_type].include? attachment_type.to_sym + [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym end end From eed2eaceb05375b63c282cc20143d630b388c6be Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:26:10 +0530 Subject: [PATCH 03/11] feat: Migrate ruby llm captain (#12981) Co-authored-by: aakashb95 Co-authored-by: Shivam Mishra --- .rubocop.yml | 2 +- enterprise/app/helpers/captain/chat_helper.rb | 84 ++++++--- .../helpers/captain/chat_response_helper.rb | 52 ++++++ .../helpers/captain/tool_execution_helper.rb | 83 --------- .../services/captain/copilot/chat_service.rb | 36 ++-- .../captain/llm/assistant_chat_service.rb | 12 +- .../captain/llm/contact_attributes_service.rb | 2 +- .../captain/llm/contact_notes_service.rb | 2 +- .../captain/llm/conversation_faq_service.rb | 2 +- .../services/captain/llm/embedding_service.rb | 2 +- .../captain/llm/faq_generator_service.rb | 2 +- .../llm/paginated_faq_generator_service.rb | 2 +- .../captain/llm/pdf_processing_service.rb | 2 +- .../onboarding/website_analyzer_service.rb | 2 +- .../app/services/captain/tools/base_tool.rb | 26 +++ .../tools/copilot/get_article_service.rb | 31 +--- .../tools/copilot/get_contact_service.rb | 31 +--- .../tools/copilot/get_conversation_service.rb | 30 +--- .../tools/copilot/search_articles_service.rb | 56 ++---- .../tools/copilot/search_contacts_service.rb | 46 +---- .../copilot/search_conversations_service.rb | 38 +--- .../copilot/search_linear_issues_service.rb | 30 +--- .../tools/search_documentation_service.rb | 25 +-- .../content_evaluator_service.rb | 2 +- .../app/services/llm/base_ai_service.rb | 33 ++++ ...vice.rb => legacy_base_open_ai_service.rb} | 10 +- .../messages/audio_transcription_service.rb | 2 +- lib/integrations/llm_instrumentation.rb | 28 +-- .../llm_instrumentation_helpers.rb | 13 ++ lib/integrations/llm_instrumentation_spans.rb | 92 ++++++++++ lib/llm/config.rb | 1 + .../captain/copilot/chat_service_spec.rb | 166 ++++-------------- .../tools/copilot/get_article_service_spec.rb | 23 +-- .../tools/copilot/get_contact_service_spec.rb | 25 +-- .../copilot/get_conversation_service_spec.rb | 29 +-- .../copilot/search_articles_service_spec.rb | 40 +---- .../copilot/search_contacts_service_spec.rb | 31 +--- .../search_conversations_service_spec.rb | 18 +- .../search_linear_issues_service_spec.rb | 31 ++-- .../search_documentation_service_spec.rb | 19 +- .../integrations/llm_instrumentation_spec.rb | 47 ----- 41 files changed, 474 insertions(+), 734 deletions(-) create mode 100644 enterprise/app/helpers/captain/chat_response_helper.rb delete mode 100644 enterprise/app/helpers/captain/tool_execution_helper.rb create mode 100644 enterprise/app/services/captain/tools/base_tool.rb create mode 100644 enterprise/app/services/llm/base_ai_service.rb rename enterprise/app/services/llm/{base_open_ai_service.rb => legacy_base_open_ai_service.rb} (69%) create mode 100644 lib/integrations/llm_instrumentation_spans.rb diff --git a/.rubocop.yml b/.rubocop.yml index f5b8a2c1c..22c4220d9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -88,7 +88,7 @@ Metrics/ModuleLength: Rails/HelperInstanceVariable: Exclude: - enterprise/app/helpers/captain/chat_helper.rb - - 'enterprise/app/helpers/captain/tool_execution_helper.rb' + - enterprise/app/helpers/captain/chat_response_helper.rb Rails/ApplicationController: Exclude: - 'app/controllers/api/v1/widget/messages_controller.rb' diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index c2f4311a7..a60e3015f 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -1,31 +1,83 @@ module Captain::ChatHelper include Integrations::LlmInstrumentation - include Captain::ToolExecutionHelper + include Captain::ChatResponseHelper def request_chat_completion log_chat_completion_request + chat = build_chat + + add_messages_to_chat(chat) with_agent_session do - response = instrument_llm_call(instrumentation_params) do - @client.chat( - parameters: chat_parameters - ) - end - handle_response(response) + response = chat.ask(conversation_messages.last[:content]) + build_response(response) end rescue StandardError => e Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}" raise e end - def instrumentation_params + private + + def build_chat + llm_chat = chat(model: @model, temperature: temperature) + llm_chat.with_params(response_format: { type: 'json_object' }) + + llm_chat = setup_tools(llm_chat) + setup_system_instructions(llm_chat) + setup_event_handlers(llm_chat) + + llm_chat + end + + def setup_tools(chat) + @tools&.each do |tool| + chat.with_tool(tool) + end + chat + end + + def setup_system_instructions(chat) + system_messages = @messages.select { |m| m[:role] == 'system' || m[:role] == :system } + combined_instructions = system_messages.pluck(:content).join("\n\n") + chat.with_instructions(combined_instructions) + end + + def setup_event_handlers(chat) + chat.on_new_message { start_llm_turn_span(instrumentation_params(chat)) } + chat.on_end_message { |message| end_llm_turn_span(message) } + chat.on_tool_call { |tool_call| handle_tool_call(tool_call) } + chat.on_tool_result { |result| handle_tool_result(result) } + + chat + end + + def handle_tool_call(tool_call) + persist_thinking_message(tool_call) + start_tool_span(tool_call) + @pending_tool_calls ||= [] + @pending_tool_calls.push(tool_call) + end + + def handle_tool_result(result) + end_tool_span(result) + persist_tool_completion + end + + def add_messages_to_chat(chat) + conversation_messages[0...-1].each do |msg| + chat.add_message(role: msg[:role].to_sym, content: msg[:content]) + end + end + + def instrumentation_params(chat = nil) { span_name: "llm.captain.#{feature_name}", account_id: resolved_account_id, conversation_id: @conversation_id, feature_name: feature_name, model: @model, - messages: @messages, + messages: chat ? chat.messages.map { |m| { role: m.role.to_s, content: m.content.to_s } } : @messages, temperature: temperature, metadata: { assistant_id: @assistant&.id @@ -33,14 +85,8 @@ module Captain::ChatHelper } end - def chat_parameters - { - model: @model, - messages: @messages, - tools: @tool_registry&.registered_tools || [], - response_format: { type: 'json_object' }, - temperature: temperature - } + def conversation_messages + @messages.reject { |m| m[:role] == 'system' || m[:role] == :system } end def temperature @@ -51,8 +97,6 @@ module Captain::ChatHelper @account&.id || @assistant&.account_id end - private - # Ensures all LLM calls and tool executions within an agentic loop # are grouped under a single trace/session in Langfuse. # @@ -78,7 +122,7 @@ module Captain::ChatHelper def log_chat_completion_request Rails.logger.info( "#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion - for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools + for messages #{@messages} with #{@tools&.length || 0} tools " ) end diff --git a/enterprise/app/helpers/captain/chat_response_helper.rb b/enterprise/app/helpers/captain/chat_response_helper.rb new file mode 100644 index 000000000..e0323996f --- /dev/null +++ b/enterprise/app/helpers/captain/chat_response_helper.rb @@ -0,0 +1,52 @@ +module Captain::ChatResponseHelper + private + + def build_response(response) + Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" } + + parsed = parse_json_response(response.content) + + persist_message(parsed, 'assistant') + parsed + end + + def parse_json_response(content) + content = content.gsub('```json', '').gsub('```', '') + content = content.strip + JSON.parse(content) + rescue JSON::ParserError => e + Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error parsing JSON response: #{e.message}" + { 'content' => content } + end + + def persist_thinking_message(tool_call) + return if @copilot_thread.blank? + + tool_name = tool_call.name.to_s + + persist_message( + { + 'content' => "Using #{tool_name}", + 'function_name' => tool_name + }, + 'assistant_thinking' + ) + end + + def persist_tool_completion + return if @copilot_thread.blank? + + tool_call = @pending_tool_calls&.pop + return unless tool_call + + tool_name = tool_call.name.to_s + + persist_message( + { + 'content' => "Completed #{tool_name}", + 'function_name' => tool_name + }, + 'assistant_thinking' + ) + end +end diff --git a/enterprise/app/helpers/captain/tool_execution_helper.rb b/enterprise/app/helpers/captain/tool_execution_helper.rb deleted file mode 100644 index 45c546c9c..000000000 --- a/enterprise/app/helpers/captain/tool_execution_helper.rb +++ /dev/null @@ -1,83 +0,0 @@ -module Captain::ToolExecutionHelper - private - - def handle_response(response) - Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" } - message = response.dig('choices', 0, 'message') - - if message['tool_calls'] - process_tool_calls(message['tool_calls']) - else - message = JSON.parse(message['content'].strip) - persist_message(message, 'assistant') - message - end - end - - def process_tool_calls(tool_calls) - append_tool_calls(tool_calls) - tool_calls.each { |tool_call| process_tool_call(tool_call) } - request_chat_completion - end - - def process_tool_call(tool_call) - arguments = JSON.parse(tool_call['function']['arguments']) - function_name = tool_call['function']['name'] - tool_call_id = tool_call['id'] - - if @tool_registry.respond_to?(function_name) - execute_tool(function_name, arguments, tool_call_id) - else - process_invalid_tool_call(function_name, tool_call_id) - end - end - - def execute_tool(function_name, arguments, tool_call_id) - persist_tool_status(function_name, 'captain.copilot.using_tool') - result = perform_tool_call(function_name, arguments) - persist_tool_status(function_name, 'captain.copilot.completed_tool_call') - append_tool_response(result, tool_call_id) - end - - def perform_tool_call(function_name, arguments) - instrument_tool_call(function_name, arguments) do - @tool_registry.send(function_name, arguments) - end - rescue StandardError => e - Rails.logger.error "Tool #{function_name} failed: #{e.message}" - "Error executing #{function_name}: #{e.message}" - end - - def persist_tool_status(function_name, translation_key) - persist_message( - { - content: I18n.t(translation_key, function_name: function_name), - function_name: function_name - }, - 'assistant_thinking' - ) - end - - def append_tool_calls(tool_calls) - @messages << { - role: 'assistant', - tool_calls: tool_calls - } - end - - def process_invalid_tool_call(function_name, tool_call_id) - persist_message( - { content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name }, - 'assistant_thinking' - ) - append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id) - end - - def append_tool_response(content, tool_call_id) - @messages << { - role: 'tool', - tool_call_id: tool_call_id, - content: content - } - end -end diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index 6db7a973b..229bf90ca 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -1,6 +1,4 @@ -require 'openai' - -class Captain::Copilot::ChatService < Llm::BaseOpenAiService +class Captain::Copilot::ChatService < Llm::BaseAiService include Captain::ChatHelper attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages @@ -14,9 +12,10 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService @copilot_thread = nil @previous_history = [] @conversation_id = config[:conversation_id] + setup_user(config) setup_message_history(config) - register_tools + @tools = build_tools @messages = build_messages(config) end @@ -60,16 +59,19 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService end end - def register_tools - @tool_registry = Captain::ToolRegistryService.new(@assistant, user: @user) - @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) - @tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService) - @tool_registry.register_tool(Captain::Tools::Copilot::GetContactService) - @tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService) - @tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService) - @tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService) - @tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService) - @tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService) + def build_tools + tools = [] + + tools << Captain::Tools::SearchDocumentationService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::GetConversationService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::SearchConversationsService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::GetContactService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::GetArticleService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::SearchArticlesService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::SearchContactsService.new(@assistant, user: @user) + tools << Captain::Tools::Copilot::SearchLinearIssuesService.new(@assistant, user: @user) + + tools.select(&:active?) end def system_message @@ -77,12 +79,16 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService role: 'system', content: Captain::Llm::SystemPromptsService.copilot_response_generator( @assistant.config['product_name'], - @tool_registry.tools_summary, + tools_summary, @assistant.config ) } end + def tools_summary + @tools.map { |tool| "- #{tool.class.name}: #{tool.class.description}" }.join("\n") + end + def account_id_context { role: 'system', diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 54e13e1d0..5a2976e39 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -1,6 +1,4 @@ -require 'openai' - -class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService +class Captain::Llm::AssistantChatService < Llm::BaseAiService include Captain::ChatHelper def initialize(assistant: nil, conversation_id: nil) @@ -8,9 +6,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService @assistant = assistant @conversation_id = conversation_id + @messages = [system_message] @response = '' - register_tools + @tools = build_tools end # additional_message: A single message (String) from the user that should be appended to the chat. @@ -28,9 +27,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService private - def register_tools - @tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil) - @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) + def build_tools + [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)] end def system_message diff --git a/enterprise/app/services/captain/llm/contact_attributes_service.rb b/enterprise/app/services/captain/llm/contact_attributes_service.rb index e7976bf27..f2c48de57 100644 --- a/enterprise/app/services/captain/llm/contact_attributes_service.rb +++ b/enterprise/app/services/captain/llm/contact_attributes_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::ContactAttributesService < Llm::BaseOpenAiService +class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService def initialize(assistant, conversation) super() @assistant = assistant diff --git a/enterprise/app/services/captain/llm/contact_notes_service.rb b/enterprise/app/services/captain/llm/contact_notes_service.rb index 480225a03..4ee4fcb5e 100644 --- a/enterprise/app/services/captain/llm/contact_notes_service.rb +++ b/enterprise/app/services/captain/llm/contact_notes_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::ContactNotesService < Llm::BaseOpenAiService +class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService def initialize(assistant, conversation) super() @assistant = assistant diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb index 077791a2c..47d1433bd 100644 --- a/enterprise/app/services/captain/llm/conversation_faq_service.rb +++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::ConversationFaqService < Llm::BaseOpenAiService +class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService DISTANCE_THRESHOLD = 0.3 def initialize(assistant, conversation) diff --git a/enterprise/app/services/captain/llm/embedding_service.rb b/enterprise/app/services/captain/llm/embedding_service.rb index 1dbb0ce5b..5190ed28a 100644 --- a/enterprise/app/services/captain/llm/embedding_service.rb +++ b/enterprise/app/services/captain/llm/embedding_service.rb @@ -1,6 +1,6 @@ require 'openai' -class Captain::Llm::EmbeddingService < Llm::BaseOpenAiService +class Captain::Llm::EmbeddingService < Llm::LegacyBaseOpenAiService class EmbeddingsError < StandardError; end def self.embedding_model diff --git a/enterprise/app/services/captain/llm/faq_generator_service.rb b/enterprise/app/services/captain/llm/faq_generator_service.rb index 5cecc0e81..634385b36 100644 --- a/enterprise/app/services/captain/llm/faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/faq_generator_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::FaqGeneratorService < Llm::BaseOpenAiService +class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService def initialize(content, language = 'english') super() @language = language diff --git a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb index 2d326f081..ebfef4b5c 100644 --- a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService +class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService # Default pages per chunk - easily configurable DEFAULT_PAGES_PER_CHUNK = 10 MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops diff --git a/enterprise/app/services/captain/llm/pdf_processing_service.rb b/enterprise/app/services/captain/llm/pdf_processing_service.rb index 026ef4e48..3ab616577 100644 --- a/enterprise/app/services/captain/llm/pdf_processing_service.rb +++ b/enterprise/app/services/captain/llm/pdf_processing_service.rb @@ -1,4 +1,4 @@ -class Captain::Llm::PdfProcessingService < Llm::BaseOpenAiService +class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService def initialize(document) super() @document = document diff --git a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb index d3e7d4983..02ba35571 100644 --- a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb +++ b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb @@ -1,4 +1,4 @@ -class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseOpenAiService +class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService MAX_CONTENT_LENGTH = 8000 def initialize(website_url) diff --git a/enterprise/app/services/captain/tools/base_tool.rb b/enterprise/app/services/captain/tools/base_tool.rb new file mode 100644 index 000000000..dbc2902d8 --- /dev/null +++ b/enterprise/app/services/captain/tools/base_tool.rb @@ -0,0 +1,26 @@ +class Captain::Tools::BaseTool < RubyLLM::Tool + attr_accessor :assistant + + def initialize(assistant, user: nil) + @assistant = assistant + @user = user + super() + end + + def active? + true + end + + private + + def user_has_permission(permission) + return false if @user.blank? + + account_user = AccountUser.find_by(account_id: @assistant.account_id, user_id: @user.id) + return false if account_user.blank? + + return account_user.custom_role.permissions.include?(permission) if account_user.custom_role.present? + + account_user.administrator? || account_user.agent? + end +end diff --git a/enterprise/app/services/captain/tools/copilot/get_article_service.rb b/enterprise/app/services/captain/tools/copilot/get_article_service.rb index 9c2ee02da..e870afd06 100644 --- a/enterprise/app/services/captain/tools/copilot/get_article_service.rb +++ b/enterprise/app/services/captain/tools/copilot/get_article_service.rb @@ -1,32 +1,11 @@ -class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseTool + def self.name 'get_article' end + description 'Get details of an article including its content and metadata' + param :article_id, type: :number, desc: 'The ID of the article to retrieve', required: true - def description - 'Get details of an article including its content and metadata' - end - - def parameters - { - type: 'object', - properties: { - article_id: { - type: 'number', - description: 'The ID of the article to retrieve' - } - }, - required: %w[article_id] - } - end - - def execute(arguments) - article_id = arguments['article_id'] - - Rails.logger.info { "#{self.class.name}: Article ID: #{article_id}" } - - return 'Missing required parameters' if article_id.blank? - + def execute(article_id:) article = Article.find_by(id: article_id, account_id: @assistant.account_id) return 'Article not found' if article.nil? diff --git a/enterprise/app/services/captain/tools/copilot/get_contact_service.rb b/enterprise/app/services/captain/tools/copilot/get_contact_service.rb index 290433301..da5453cae 100644 --- a/enterprise/app/services/captain/tools/copilot/get_contact_service.rb +++ b/enterprise/app/services/captain/tools/copilot/get_contact_service.rb @@ -1,32 +1,11 @@ -class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseTool + def self.name 'get_contact' end + description 'Get details of a contact including their profile information' + param :contact_id, type: :number, desc: 'The ID of the contact to retrieve', required: true - def description - 'Get details of a contact including their profile information' - end - - def parameters - { - type: 'object', - properties: { - contact_id: { - type: 'number', - description: 'The ID of the contact to retrieve' - } - }, - required: %w[contact_id] - } - end - - def execute(arguments) - contact_id = arguments['contact_id'] - - Rails.logger.info "#{self.class.name}: Contact ID: #{contact_id}" - - return 'Missing required parameters' if contact_id.blank? - + def execute(contact_id:) contact = Contact.find_by(id: contact_id, account_id: @assistant.account_id) return 'Contact not found' if contact.nil? diff --git a/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb index 6f942ee8e..6b52a147d 100644 --- a/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb +++ b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb @@ -1,32 +1,12 @@ -class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseTool + def self.name 'get_conversation' end + description 'Get details of a conversation including messages and contact information' - def description - 'Get details of a conversation including messages and contact information' - end - - def parameters - { - type: 'object', - properties: { - conversation_id: { - type: 'number', - description: 'The ID of the conversation to retrieve' - } - }, - required: %w[conversation_id] - } - end - - def execute(arguments) - conversation_id = arguments['conversation_id'] - - Rails.logger.info "#{self.class.name}: Conversation ID: #{conversation_id}" - - return 'Missing required parameters' if conversation_id.blank? + param :conversation_id, type: :integer, desc: 'ID of the conversation to retrieve', required: true + def execute(conversation_id:) conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id) return 'Conversation not found' if conversation.blank? diff --git a/enterprise/app/services/captain/tools/copilot/search_articles_service.rb b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb index 5061968ad..24385a932 100644 --- a/enterprise/app/services/captain/tools/copilot/search_articles_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb @@ -1,36 +1,22 @@ -class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseTool + def self.name 'search_articles' end - - def description - 'Search articles based on parameters' + description 'Search articles based on parameters' + params do + string :query, description: 'Search articles by title or content (partial match)' + number :category_id, description: 'Filter articles by category ID' + any_of :status, description: 'Filter articles by status' do + string enum: %w[draft published archived] + end end - def parameters - { - type: 'object', - properties: properties, - required: ['query'] - } - end - - def execute(arguments) - query = arguments['query'] - category_id = arguments['category_id'] - status = arguments['status'] - - Rails.logger.info "#{self.class.name}: Query: #{query}, Category ID: #{category_id}, Status: #{status}" - - return 'Missing required parameters' if query.blank? - - articles = fetch_articles(query, category_id, status) - + def execute(query: nil, category_id: nil, status: nil) + articles = fetch_articles(query: query, category_id: category_id, status: status) return 'No articles found' unless articles.exists? total_count = articles.count articles = articles.limit(100) - <<~RESPONSE #{total_count > 100 ? "Found #{total_count} articles (showing first 100)" : "Total number of articles: #{total_count}"} #{articles.map(&:to_llm_text).join("\n---\n")} @@ -43,29 +29,11 @@ class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseServi private - def fetch_articles(query, category_id, status) + def fetch_articles(query:, category_id:, status:) articles = Article.where(account_id: @assistant.account_id) articles = articles.where('title ILIKE :query OR content ILIKE :query', query: "%#{query}%") if query.present? articles = articles.where(category_id: category_id) if category_id.present? articles = articles.where(status: status) if status.present? articles end - - def properties - { - query: { - type: 'string', - description: 'Search articles by title or content (partial match)' - }, - category_id: { - type: 'number', - description: 'Filter articles by category ID' - }, - status: { - type: 'string', - enum: %w[draft published archived], - description: 'Filter articles by status' - } - } - end end diff --git a/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb b/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb index 557fc731a..5c49cf674 100644 --- a/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb @@ -1,27 +1,14 @@ -class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseTool + def self.name 'search_contacts' end - def description - 'Search contacts based on query parameters' - end - - def parameters - { - type: 'object', - properties: properties, - required: [] - } - end - - def execute(arguments) - email = arguments['email'] - phone_number = arguments['phone_number'] - name = arguments['name'] - - Rails.logger.info "#{self.class.name} Email: #{email}, Phone Number: #{phone_number}, Name: #{name}" + description 'Search contacts based on query parameters' + param :email, type: :string, desc: 'Filter contacts by email' + param :phone_number, type: :string, desc: 'Filter contacts by phone number' + param :name, type: :string, desc: 'Filter contacts by name (partial match)' + def execute(email: nil, phone_number: nil, name: nil) contacts = Contact.where(account_id: @assistant.account_id) contacts = contacts.where(email: email) if email.present? contacts = contacts.where(phone_number: phone_number) if phone_number.present? @@ -39,23 +26,4 @@ class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseServi def active? user_has_permission('contact_manage') end - - private - - def properties - { - email: { - type: 'string', - description: 'Filter contacts by email' - }, - phone_number: { - type: 'string', - description: 'Filter contacts by phone number' - }, - name: { - type: 'string', - description: 'Filter contacts by name (partial match)' - } - } - end end diff --git a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb index 6dfdf3897..9e824d1f5 100644 --- a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb @@ -1,26 +1,15 @@ -class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseService - def name - 'search_conversations' +class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseTool + def self.name + 'search_conversation' end + description 'Search conversations based on parameters' - def description - 'Search conversations based on parameters' - end - - def parameters - { - type: 'object', - properties: properties, - required: [] - } - end - - def execute(arguments) - status = arguments['status'] - contact_id = arguments['contact_id'] - priority = arguments['priority'] - labels = arguments['labels'] + param :status, type: :string, desc: 'Status of the conversation' + param :contact_id, type: :number, desc: 'Contact id' + param :priority, type: :string, desc: 'Priority of conversation' + param :labels, type: :string, desc: 'Labels available' + def execute(status: nil, contact_id: nil, priority: nil, labels: nil) conversations = get_conversations(status, contact_id, priority, labels) return 'No conversations found' unless conversations.exists? @@ -58,13 +47,4 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base @assistant.account ).perform end - - def properties - { - contact_id: { type: 'number', description: 'Filter conversations by contact ID' }, - status: { type: 'string', enum: %w[open resolved pending snoozed], description: 'Filter conversations by status' }, - priority: { type: 'string', enum: %w[low medium high urgent], description: 'Filter conversations by priority' }, - labels: { type: 'array', items: { type: 'string' }, description: 'Filter conversations by labels' } - } - end end diff --git a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb index 0d59e194d..0d19534e7 100644 --- a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb @@ -1,34 +1,14 @@ -class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService - def name +class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseTool + def self.name 'search_linear_issues' end - def description - 'Search Linear issues based on a search term' - end + description 'Search Linear issues based on a search term' + param :term, type: :string, desc: 'The search term to find Linear issues', required: true - def parameters - { - type: 'object', - properties: { - term: { - type: 'string', - description: 'The search term to find Linear issues' - } - }, - required: %w[term] - } - end - - def execute(arguments) + def execute(term:) return 'Linear integration is not enabled' unless active? - term = arguments['term'] - - Rails.logger.info "#{self.class.name}: Service called with the search term #{term}" - - return 'Missing required parameters' if term.blank? - linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account) result = linear_service.search_issue(term) diff --git a/enterprise/app/services/captain/tools/search_documentation_service.rb b/enterprise/app/services/captain/tools/search_documentation_service.rb index 672baf24a..fbc8f4154 100644 --- a/enterprise/app/services/captain/tools/search_documentation_service.rb +++ b/enterprise/app/services/captain/tools/search_documentation_service.rb @@ -1,27 +1,12 @@ -class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseService - def name +class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool + def self.name 'search_documentation' end + description 'Search and retrieve documentation from knowledge base' - def description - 'Search and retrieve documentation from knowledge base' - end + param :query, desc: 'Search Query', required: true - def parameters - { - type: 'object', - properties: { - search_query: { - type: 'string', - description: 'The search query to look up in the documentation.' - } - }, - required: ['search_query'] - } - end - - def execute(arguments) - query = arguments['search_query'] + def execute(query:) Rails.logger.info { "#{self.class.name}: #{query}" } responses = assistant.responses.approved.search(query) diff --git a/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb b/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb index e88c46091..586e9fad0 100644 --- a/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb +++ b/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb @@ -1,4 +1,4 @@ -class Internal::AccountAnalysis::ContentEvaluatorService < Llm::BaseOpenAiService +class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAiService def initialize super() diff --git a/enterprise/app/services/llm/base_ai_service.rb b/enterprise/app/services/llm/base_ai_service.rb new file mode 100644 index 000000000..504685e3a --- /dev/null +++ b/enterprise/app/services/llm/base_ai_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Base service for LLM operations using RubyLLM. +# New features should inherit from this class. +class Llm::BaseAiService + DEFAULT_MODEL = Llm::Config::DEFAULT_MODEL + DEFAULT_TEMPERATURE = 1.0 + + attr_reader :model, :temperature + + def initialize + Llm::Config.initialize! + setup_model + setup_temperature + end + + # Returns a configured RubyLLM chat instance. + # Subclasses can override model/temperature via instance variables or pass them explicitly. + def chat(model: @model, temperature: @temperature) + RubyLLM.chat(model: model).with_temperature(temperature) + end + + private + + def setup_model + config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value + @model = (config_value.presence || DEFAULT_MODEL) + end + + def setup_temperature + @temperature = DEFAULT_TEMPERATURE + end +end diff --git a/enterprise/app/services/llm/base_open_ai_service.rb b/enterprise/app/services/llm/legacy_base_open_ai_service.rb similarity index 69% rename from enterprise/app/services/llm/base_open_ai_service.rb rename to enterprise/app/services/llm/legacy_base_open_ai_service.rb index e3e88453c..ede9f0d8f 100644 --- a/enterprise/app/services/llm/base_open_ai_service.rb +++ b/enterprise/app/services/llm/legacy_base_open_ai_service.rb @@ -1,5 +1,11 @@ -class Llm::BaseOpenAiService - DEFAULT_MODEL = 'gpt-4o-mini'.freeze +# frozen_string_literal: true + +# DEPRECATED: This class uses the legacy OpenAI Ruby gem directly. +# New features should use Llm::BaseAiService with RubyLLM instead. +# This class will be removed once all services are migrated to RubyLLM. +class Llm::LegacyBaseOpenAiService + DEFAULT_MODEL = 'gpt-4o-mini' + attr_reader :client, :model def initialize diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 278f41d71..a200c1496 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -1,4 +1,4 @@ -class Messages::AudioTranscriptionService < Llm::BaseOpenAiService +class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService attr_reader :attachment, :message, :account def initialize(attachment) diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb index 7c176b758..aff41fbce 100644 --- a/lib/integrations/llm_instrumentation.rb +++ b/lib/integrations/llm_instrumentation.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true require 'opentelemetry_config' -require_relative 'llm_instrumentation_constants' -require_relative 'llm_instrumentation_helpers' module Integrations::LlmInstrumentation include Integrations::LlmInstrumentationConstants include Integrations::LlmInstrumentationHelpers + include Integrations::LlmInstrumentationSpans PROVIDER_PREFIXES = { 'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-], @@ -16,10 +15,6 @@ module Integrations::LlmInstrumentation 'deepseek' => %w[deepseek-] }.freeze - def tracer - @tracer ||= OpentelemetryConfig.tracer - end - def instrument_llm_call(params) return yield unless ChatwootApp.otel_enabled? @@ -43,6 +38,7 @@ module Integrations::LlmInstrumentation result = nil executed = false tracer.in_span(params[:span_name]) do |span| + set_request_attributes(span, params) set_metadata_attributes(span, params) # By default, the input and output of a trace are set from the root observation @@ -98,7 +94,12 @@ module Integrations::LlmInstrumentation end def record_completion(span, result) - set_completion_attributes(span, result) if result.is_a?(Hash) + if result.respond_to?(:content) + span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role) + span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s) + elsif result.is_a?(Hash) + set_completion_attributes(span, result) if result.is_a?(Hash) + end end def set_request_attributes(span, params) @@ -117,17 +118,4 @@ module Integrations::LlmInstrumentation span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s) end end - - def set_metadata_attributes(span, params) - session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil - span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] - span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present? - span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) - - return unless params[:metadata].is_a?(Hash) - - params[:metadata].each do |key, value| - span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s) - end - end end diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb index d1bf5c25c..1fc73f3f0 100644 --- a/lib/integrations/llm_instrumentation_helpers.rb +++ b/lib/integrations/llm_instrumentation_helpers.rb @@ -37,4 +37,17 @@ module Integrations::LlmInstrumentationHelpers span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}") end + + def set_metadata_attributes(span, params) + session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil + span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] + span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present? + span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) + + return unless params[:metadata].is_a?(Hash) + + params[:metadata].each do |key, value| + span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s) + end + end end diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb new file mode 100644 index 000000000..9199aaa10 --- /dev/null +++ b/lib/integrations/llm_instrumentation_spans.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'opentelemetry_config' +require_relative 'llm_instrumentation_constants' + +module Integrations::LlmInstrumentationSpans + include Integrations::LlmInstrumentationConstants + + def tracer + @tracer ||= OpentelemetryConfig.tracer + end + + def start_llm_turn_span(params) + return unless ChatwootApp.otel_enabled? + + span = tracer.start_span(params[:span_name]) + set_llm_turn_request_attributes(span, params) + set_llm_turn_prompt_attributes(span, params[:messages]) if params[:messages] + + @pending_llm_turn_spans ||= [] + @pending_llm_turn_spans.push(span) + rescue StandardError => e + Rails.logger.warn "Failed to start LLM turn span: #{e.message}" + end + + def end_llm_turn_span(message) + return unless ChatwootApp.otel_enabled? + + span = @pending_llm_turn_spans&.pop + return unless span + + set_llm_turn_response_attributes(span, message) if message + span.finish + rescue StandardError => e + Rails.logger.warn "Failed to end LLM turn span: #{e.message}" + end + + def start_tool_span(tool_call) + return unless ChatwootApp.otel_enabled? + + tool_name = tool_call.name.to_s + span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name)) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json) + + @pending_tool_spans ||= [] + @pending_tool_spans.push(span) + rescue StandardError => e + Rails.logger.warn "Failed to start tool span: #{e.message}" + end + + def end_tool_span(result) + return unless ChatwootApp.otel_enabled? + + span = @pending_tool_spans&.pop + return unless span + + output = result.is_a?(String) ? result : result.to_json + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output) + span.finish + rescue StandardError => e + Rails.logger.warn "Failed to end tool span: #{e.message}" + end + + private + + def set_llm_turn_request_attributes(span, params) + provider = determine_provider(params[:model]) + span.set_attribute(ATTR_GEN_AI_PROVIDER, provider) + span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) if params[:model] + span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature] + end + + def set_llm_turn_prompt_attributes(span, messages) + messages.each_with_index do |msg, idx| + span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), msg[:role]) + span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), msg[:content]) + end + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, messages.to_json) + end + + def set_llm_turn_response_attributes(span, message) + span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, message.role.to_s) if message.respond_to?(:role) + span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.content.to_s) if message.respond_to?(:content) + set_llm_turn_usage_attributes(span, message) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content) + end + + def set_llm_turn_usage_attributes(span, message) + span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) if message.respond_to?(:input_tokens) && message.input_tokens + span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens) && message.output_tokens + end +end diff --git a/lib/llm/config.rb b/lib/llm/config.rb index b2edc81e7..94836d746 100644 --- a/lib/llm/config.rb +++ b/lib/llm/config.rb @@ -33,6 +33,7 @@ module Llm::Config RubyLLM.configure do |config| config.openai_api_key = system_api_key if system_api_key.present? config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present? + config.logger = Rails.logger end end diff --git a/spec/enterprise/services/captain/copilot/chat_service_spec.rb b/spec/enterprise/services/captain/copilot/chat_service_spec.rb index a02063b5e..4903fb6a5 100644 --- a/spec/enterprise/services/captain/copilot/chat_service_spec.rb +++ b/spec/enterprise/services/captain/copilot/chat_service_spec.rb @@ -7,7 +7,6 @@ RSpec.describe Captain::Copilot::ChatService do let(:assistant) { create(:captain_assistant, account: account) } let(:contact) { create(:contact, account: account) } let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } - let(:mock_openai_client) { instance_double(OpenAI::Client) } let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) } let!(:copilot_message) do create( @@ -20,13 +19,29 @@ RSpec.describe Captain::Copilot::ChatService do { user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id } end + # RubyLLM mocks + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:mock_response) do + instance_double(RubyLLM::Message, content: '{ "content": "Hey", "reasoning": "Test reasoning", "reply_suggestion": false }') + end + before do - create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') - create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://api.openai.com/') - allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client) - allow(mock_openai_client).to receive(:chat).and_return({ - choices: [{ message: { content: '{ "content": "Hey" }' } }] - }.with_indifferent_access) + InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |c| + c.value = 'test-key' + end + + allow(RubyLLM).to receive(:chat).and_return(mock_chat) + allow(mock_chat).to receive(:with_temperature).and_return(mock_chat) + allow(mock_chat).to receive(:with_params).and_return(mock_chat) + allow(mock_chat).to receive(:with_tool).and_return(mock_chat) + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:add_message).and_return(mock_chat) + allow(mock_chat).to receive(:on_new_message).and_return(mock_chat) + allow(mock_chat).to receive(:on_end_message).and_return(mock_chat) + allow(mock_chat).to receive(:on_tool_call).and_return(mock_chat) + allow(mock_chat).to receive(:on_tool_result).and_return(mock_chat) + allow(mock_chat).to receive(:messages).and_return([]) + allow(mock_chat).to receive(:ask).and_return(mock_response) end describe '#initialize' do @@ -48,48 +63,6 @@ RSpec.describe Captain::Copilot::ChatService do expect(messages.second[:role]).to eq('system') expect(messages.second[:content]).to include(account.id.to_s) end - - it 'initializes OpenAI client with configured endpoint' do - expect(OpenAI::Client).to receive(:new).with( - access_token: 'test-key', - uri_base: 'https://api.openai.com/', - log_errors: Rails.env.development? - ) - - described_class.new(assistant, config) - end - - context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do - before do - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy - end - - it 'uses default OpenAI endpoint' do - expect(OpenAI::Client).to receive(:new).with( - access_token: 'test-key', - uri_base: 'https://api.openai.com/', - log_errors: Rails.env.development? - ) - - described_class.new(assistant, config) - end - end - - context 'when custom endpoint is configured' do - before do - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').update!(value: 'https://custom.azure.com/') - end - - it 'uses custom endpoint for OpenAI client' do - expect(OpenAI::Client).to receive(:new).with( - access_token: 'test-key', - uri_base: 'https://custom.azure.com/', - log_errors: Rails.env.development? - ) - - described_class.new(assistant, config) - end - end end describe '#generate_response' do @@ -112,82 +85,19 @@ RSpec.describe Captain::Copilot::ChatService do end it 'returns the response from request_chat_completion' do - expect(service.generate_response('Hello')).to eq({ 'content' => 'Hey' }) + result = service.generate_response('Hello') + + expect(result).to eq({ 'content' => 'Hey', 'reasoning' => 'Test reasoning', 'reply_suggestion' => false }) end - context 'when response contains tool calls' do - before do - allow(mock_openai_client).to receive(:chat).and_return( - { - choices: [{ message: { 'tool_calls' => tool_calls } }] - }.with_indifferent_access, - { - choices: [{ message: { content: '{ "content": "Tool response processed" }' } }] - }.with_indifferent_access - ) - end - - context 'when tool call is valid' do - let(:tool_calls) do - [{ - 'id' => 'call_123', - 'function' => { - 'name' => 'get_conversation', - 'arguments' => "{ \"conversation_id\": #{conversation.display_id} }" - } - }] - end - - it 'processes tool calls and appends them to messages' do - result = service.generate_response("Find conversation #{conversation.id}") - - expect(result).to eq({ 'content' => 'Tool response processed' }) - expect(service.messages).to include( - { role: 'assistant', tool_calls: tool_calls } - ) - expect(service.messages).to include( - { - role: 'tool', tool_call_id: 'call_123', content: conversation.to_llm_text - } - ) - - expect(result).to eq({ 'content' => 'Tool response processed' }) - end - end - - context 'when tool call is invalid' do - let(:tool_calls) do - [{ - 'id' => 'call_123', - 'function' => { - 'name' => 'get_settings', - 'arguments' => '{}' - } - }] - end - - it 'handles invalid tool calls' do - result = service.generate_response('Find settings') - - expect(result).to eq({ 'content' => 'Tool response processed' }) - expect(service.messages).to include( - { - role: 'assistant', tool_calls: tool_calls - } - ) - expect(service.messages).to include( - { - role: 'tool', - tool_call_id: 'call_123', - content: 'Tool not available' - } - ) - end - end + it 'increments response usage for the account' do + expect do + service.generate_response('Hello') + end.to(change { account.reload.custom_attributes['captain_responses_usage'].to_i }.by(1)) end end - describe '#setup_user' do + describe 'user setup behavior' do it 'sets user when user_id is present in config' do service = described_class.new(assistant, { user_id: user.id }) expect(service.user).to eq(user) @@ -199,7 +109,7 @@ RSpec.describe Captain::Copilot::ChatService do end end - describe '#setup_message_history' do + describe 'message history behavior' do context 'when copilot_thread_id is present' do it 'finds the copilot thread and sets previous history from it' do service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id }) @@ -227,7 +137,7 @@ RSpec.describe Captain::Copilot::ChatService do end end - describe '#build_messages' do + describe 'message building behavior' do it 'includes system message and account context' do service = described_class.new(assistant, {}) messages = service.messages @@ -257,13 +167,9 @@ RSpec.describe Captain::Copilot::ChatService do end end - describe '#persist_message' do + describe 'message persistence behavior' do context 'when copilot_thread is present' do - it 'creates a copilot message' do - allow(mock_openai_client).to receive(:chat).and_return({ - choices: [{ message: { content: '{ "content": "Hey" }' } }] - }.with_indifferent_access) - + it 'creates a copilot message with the response' do expect do described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello') end.to change(CopilotMessage, :count).by(1) @@ -276,10 +182,6 @@ RSpec.describe Captain::Copilot::ChatService do context 'when copilot_thread is not present' do it 'does not create a copilot message' do - allow(mock_openai_client).to receive(:chat).and_return({ - choices: [{ message: { content: '{ "content": "Hey" }' } }] - }.with_indifferent_access) - expect do described_class.new(assistant, {}).generate_response('Hello') end.not_to(change(CopilotMessage, :count)) diff --git a/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb index 72f4e1cb4..5227a3201 100644 --- a/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb @@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - article_id: { - type: 'number', - description: 'The ID of the article to retrieve' - } - }, - required: %w[article_id] - } - ) + it 'defines article_id parameter' do + expect(service.parameters.keys).to contain_exactly(:article_id) end end @@ -79,13 +68,13 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do describe '#execute' do context 'when article_id is blank' do it 'returns error message' do - expect(service.execute({})).to eq('Missing required parameters') + expect(service.execute(article_id: nil)).to eq('Article not found') end end context 'when article is not found' do it 'returns not found message' do - expect(service.execute({ 'article_id' => 999 })).to eq('Article not found') + expect(service.execute(article_id: 999)).to eq('Article not found') end end @@ -94,7 +83,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do let(:article) { create(:article, account: account, portal: portal, author: user, title: 'Test Article', content: 'Content') } it 'returns the article in llm text format' do - result = service.execute({ 'article_id' => article.id }) + result = service.execute(article_id: article.id) expect(result).to eq(article.to_llm_text) end @@ -104,7 +93,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do let(:other_article) { create(:article, account: other_account, portal: other_portal, author: user, title: 'Other Article') } it 'returns not found message' do - expect(service.execute({ 'article_id' => other_article.id })).to eq('Article not found') + expect(service.execute(article_id: other_article.id)).to eq('Article not found') end end end diff --git a/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb index de319bfa1..df2e5c159 100644 --- a/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb @@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - contact_id: { - type: 'number', - description: 'The ID of the contact to retrieve' - } - }, - required: %w[contact_id] - } - ) + it 'defines contact_id parameter' do + expect(service.parameters.keys).to contain_exactly(:contact_id) end end @@ -78,14 +67,14 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do describe '#execute' do context 'when contact_id is blank' do - it 'returns error message' do - expect(service.execute({})).to eq('Missing required parameters') + it 'returns not found message' do + expect(service.execute(contact_id: nil)).to eq('Contact not found') end end context 'when contact is not found' do it 'returns not found message' do - expect(service.execute({ 'contact_id' => 999 })).to eq('Contact not found') + expect(service.execute(contact_id: 999)).to eq('Contact not found') end end @@ -93,7 +82,7 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do let(:contact) { create(:contact, account: account) } it 'returns the contact in llm text format' do - result = service.execute({ 'contact_id' => contact.id }) + result = service.execute(contact_id: contact.id) expect(result).to eq(contact.to_llm_text) end @@ -102,7 +91,7 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do let(:other_contact) { create(:contact, account: other_account) } it 'returns not found message' do - expect(service.execute({ 'contact_id' => other_contact.id })).to eq('Contact not found') + expect(service.execute(contact_id: other_contact.id)).to eq('Contact not found') end end end diff --git a/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb index b8265bbbf..ad2a78cc3 100644 --- a/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb @@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - conversation_id: { - type: 'number', - description: 'The ID of the conversation to retrieve' - } - }, - required: %w[conversation_id] - } - ) + it 'defines conversation_id parameter' do + expect(service.parameters.keys).to contain_exactly(:conversation_id) end end @@ -107,15 +96,9 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do end describe '#execute' do - context 'when conversation_id is blank' do - it 'returns error message' do - expect(service.execute({})).to eq('Missing required parameters') - end - end - context 'when conversation is not found' do it 'returns not found message' do - expect(service.execute({ 'conversation_id' => 999 })).to eq('Conversation not found') + expect(service.execute(conversation_id: 999)).to eq('Conversation not found') end end @@ -124,7 +107,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do let(:conversation) { create(:conversation, account: account, inbox: inbox) } it 'returns the conversation in llm text format' do - result = service.execute({ 'conversation_id' => conversation.display_id }) + result = service.execute(conversation_id: conversation.display_id) expect(result).to eq(conversation.to_llm_text) end @@ -143,7 +126,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do content: 'Private note content', private: true) - result = service.execute({ 'conversation_id' => conversation.display_id }) + result = service.execute(conversation_id: conversation.display_id) # Verify that the result includes both regular and private messages expect(result).to include('Regular message') @@ -157,7 +140,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do let(:other_conversation) { create(:conversation, account: other_account, inbox: other_inbox) } it 'returns not found message' do - expect(service.execute({ 'conversation_id' => other_conversation.display_id })).to eq('Conversation not found') + expect(service.execute(conversation_id: other_conversation.display_id)).to eq('Conversation not found') end end end diff --git a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb index eb11e0d69..5623dce3d 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb @@ -18,32 +18,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end end - describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Search articles by title or content (partial match)' - }, - category_id: { - type: 'number', - description: 'Filter articles by category ID' - }, - status: { - type: 'string', - enum: %w[draft published archived], - description: 'Filter articles by status' - } - }, - required: ['query'] - } - ) - end - end - describe '#active?' do context 'when user is an admin' do let(:user) { create(:user, :administrator, account: account) } @@ -95,15 +69,9 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end describe '#execute' do - context 'when query is blank' do - it 'returns error message' do - expect(service.execute({})).to eq('Missing required parameters') - end - end - context 'when no articles are found' do it 'returns no articles found message' do - expect(service.execute({ 'query' => 'test' })).to eq('No articles found') + expect(service.execute(query: 'test', category_id: nil, status: nil)).to eq('No articles found') end end @@ -113,7 +81,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') } it 'returns formatted articles with count' do - result = service.execute({ 'query' => 'Test' }) + result = service.execute(query: 'Test', category_id: nil, status: nil) expect(result).to include('Total number of articles: 2') expect(result).to include(article1.to_llm_text) expect(result).to include(article2.to_llm_text) @@ -124,7 +92,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') } it 'returns only articles from the specified category' do - result = service.execute({ 'query' => 'Test', 'category_id' => category.id }) + result = service.execute(query: 'Test', category_id: category.id, status: nil) expect(result).to include('Total number of articles: 1') expect(result).to include(article3.to_llm_text) expect(result).not_to include(article1.to_llm_text) @@ -137,7 +105,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') } it 'returns only articles with the specified status' do - result = service.execute({ 'query' => 'Test', 'status' => 'published' }) + result = service.execute(query: 'Test', category_id: nil, status: 'published') expect(result).to include(article3.to_llm_text) expect(result).not_to include(article4.to_llm_text) end diff --git a/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb index f54b2eddf..09bd09ec2 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb @@ -19,27 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchContactsService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - email: { - type: 'string', - description: 'Filter contacts by email' - }, - phone_number: { - type: 'string', - description: 'Filter contacts by phone number' - }, - name: { - type: 'string', - description: 'Filter contacts by name (partial match)' - } - }, - required: [] - } - ) + it 'defines email, phone_number, and name parameters' do + expect(service.parameters.keys).to contain_exactly(:email, :phone_number, :name) end end @@ -86,25 +67,25 @@ RSpec.describe Captain::Tools::Copilot::SearchContactsService do end it 'returns contacts when filtered by email' do - result = service.execute({ 'email' => 'test1@example.com' }) + result = service.execute(email: 'test1@example.com') expect(result).to include(contact1.to_llm_text) expect(result).not_to include(contact2.to_llm_text) end it 'returns contacts when filtered by phone number' do - result = service.execute({ 'phone_number' => '+1234567890' }) + result = service.execute(phone_number: '+1234567890') expect(result).to include(contact1.to_llm_text) expect(result).not_to include(contact2.to_llm_text) end it 'returns contacts when filtered by name' do - result = service.execute({ 'name' => 'Contact 1' }) + result = service.execute(name: 'Contact 1') expect(result).to include(contact1.to_llm_text) expect(result).not_to include(contact2.to_llm_text) end it 'returns all matching contacts when no filters are provided' do - result = service.execute({}) + result = service.execute expect(result).to include(contact1.to_llm_text) expect(result).to include(contact2.to_llm_text) end diff --git a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb index ab0865bc2..a02d05404 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do describe '#name' do it 'returns the correct service name' do - expect(service.name).to eq('search_conversations') + expect(service.name).to eq('search_conversation') end end @@ -19,10 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do end describe '#parameters' do - it 'returns the correct parameter schema' do - params = service.parameters - expect(params[:type]).to eq('object') - expect(params[:properties]).to include(:contact_id, :status, :priority) + it 'defines the expected parameters' do + expect(service.parameters.keys).to contain_exactly(:status, :contact_id, :priority, :labels) end end @@ -90,35 +88,35 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') } it 'returns all conversations when no filters are applied' do - result = service.execute({}) + result = service.execute expect(result).to include('Total number of conversations: 2') expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true)) end it 'filters conversations by status' do - result = service.execute({ 'status' => 'open' }) + result = service.execute(status: 'open') expect(result).to include('Total number of conversations: 1') expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true)) end it 'filters conversations by contact_id' do - result = service.execute({ 'contact_id' => contact.id }) + result = service.execute(contact_id: contact.id) expect(result).to include('Total number of conversations: 1') expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true)) end it 'filters conversations by priority' do - result = service.execute({ 'priority' => 'high' }) + result = service.execute(priority: 'high') expect(result).to include('Total number of conversations: 1') expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true)) end it 'returns appropriate message when no conversations are found' do - result = service.execute({ 'status' => 'snoozed' }) + result = service.execute(status: 'snoozed') expect(result).to eq('No conversations found') end end diff --git a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb index f987b7a6a..a88504bb1 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb @@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end describe '#parameters' do - it 'returns the expected parameter schema' do - expect(service.parameters).to eq( - { - type: 'object', - properties: { - term: { - type: 'string', - description: 'The search term to find Linear issues' - } - }, - required: %w[term] - } - ) + it 'defines term parameter' do + expect(service.parameters.keys).to contain_exactly(:term) end end @@ -76,7 +65,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do describe '#execute' do context 'when Linear integration is not enabled' do it 'returns error message' do - expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled') + expect(service.execute(term: 'test')).to eq('Linear integration is not enabled') end end @@ -89,8 +78,12 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end context 'when term is blank' do - it 'returns error message' do - expect(service.execute({ 'term' => '' })).to eq('Missing required parameters') + before do + allow(linear_service).to receive(:search_issue).with('').and_return({ data: [] }) + end + + it 'returns no issues found message' do + expect(service.execute(term: '')).to eq('No issues found, I should try another similar search term') end end @@ -100,7 +93,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end it 'returns the error message' do - expect(service.execute({ 'term' => 'test' })).to eq('API Error') + expect(service.execute(term: 'test')).to eq('API Error') end end @@ -110,7 +103,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end it 'returns no issues found message' do - expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term') + expect(service.execute(term: 'test')).to eq('No issues found, I should try another similar search term') end end @@ -131,7 +124,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do end it 'returns formatted issues' do - result = service.execute({ 'term' => 'test' }) + result = service.execute(term: 'test') expect(result).to include('Total number of issues: 1') expect(result).to include('Title: Test Issue') expect(result).to include('ID: TEST-123') diff --git a/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb b/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb index 9f5586e6b..41cec35f5 100644 --- a/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb +++ b/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb @@ -20,19 +20,8 @@ RSpec.describe Captain::Tools::SearchDocumentationService do end describe '#parameters' do - it 'returns the required parameters schema' do - expected_schema = { - type: 'object', - properties: { - search_query: { - type: 'string', - description: 'The search query to look up in the documentation.' - } - }, - required: ['search_query'] - } - - expect(service.parameters).to eq(expected_schema) + it 'defines query parameter' do + expect(service.parameters.keys).to contain_exactly(:query) end end @@ -56,7 +45,7 @@ RSpec.describe Captain::Tools::SearchDocumentationService do end it 'returns formatted responses for the search query' do - result = service.execute({ 'search_query' => question }) + result = service.execute(query: question) expect(result).to include(question) expect(result).to include(answer) @@ -70,7 +59,7 @@ RSpec.describe Captain::Tools::SearchDocumentationService do end it 'returns an empty string' do - expect(service.execute({ 'search_query' => question })).to eq('No FAQs found for the given query') + expect(service.execute(query: question)).to eq('No FAQs found for the given query') end end end diff --git a/spec/lib/integrations/llm_instrumentation_spec.rb b/spec/lib/integrations/llm_instrumentation_spec.rb index f7f0c45e4..f567a91b7 100644 --- a/spec/lib/integrations/llm_instrumentation_spec.rb +++ b/spec/lib/integrations/llm_instrumentation_spec.rb @@ -256,52 +256,5 @@ RSpec.describe Integrations::LlmInstrumentation do end end end - - describe '#instrument_tool_call' do - let(:tool_name) { 'search_documents' } - let(:arguments) { { query: 'test query' } } - - context 'when OTEL provider is not configured' do - before { otel_config.update(value: '') } - - it 'executes the block without tracing' do - result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' } - expect(result).to eq('tool_result') - end - end - - context 'when OTEL provider is configured' do - let(:mock_span) { instance_double(OpenTelemetry::Trace::Span) } - let(:mock_tracer) { instance_double(OpenTelemetry::Trace::Tracer) } - - before do - allow(mock_span).to receive(:set_attribute) - allow(instance).to receive(:tracer).and_return(mock_tracer) - allow(mock_tracer).to receive(:in_span).and_yield(mock_span) - end - - it 'executes the block and returns the result' do - result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' } - expect(result).to eq('tool_result') - end - - it 'propagates instrumentation errors' do - allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed')) - - expect do - instance.instrument_tool_call(tool_name, arguments) { 'tool_result' } - end.to raise_error(StandardError, 'Instrumentation failed') - end - - it 'creates a span with tool name and sets observation attributes' do - tool_result = { documents: ['doc1'] } - instance.instrument_tool_call(tool_name, arguments) { tool_result } - - expect(mock_tracer).to have_received(:in_span).with('tool.search_documents') - expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', arguments.to_json) - expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', tool_result.to_json) - end - end - end end end From efc3b5e7d44ce0514a1c2533df98182a7205bf82 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 4 Dec 2025 18:53:50 +0530 Subject: [PATCH 04/11] fix: Handle Instagram API error codes properly in message processing (#13002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Problem Instagram webhook processing was failing with: > TypeError: no implicit conversion of String into Integer This occurred when handling **echo messages** (outgoing messages) that: * Contained `unsupported_type` attachments, and * Were sent to a recipient user that could **not** be fetched via the Instagram API. In these cases, the webhook job crashed while trying to create or find the contact for the recipient. ### Root Cause The Instagram message service does not correctly handle Instagram API error code **100**: > "Object with ID does not exist, cannot be loaded due to missing permissions, or does not support this operation" When this error occurred during `fetch_instagram_user`: 1. The method fell through to exception tracking without an explicit return. 2. `ChatwootExceptionTracker.capture_exception` returned `true`. 3. As a result, `fetch_instagram_user` effectively returned `true` instead of a hash or empty hash. 4. `ensure_contact` then called `find_or_create_contact(true)` because `true.present?` is `true`. 5. `find_or_create_contact` crashed when it tried to access `true['id']`. So the chain was: ```txt fetch_instagram_user -> returns true ensure_contact -> find_or_create_contact(true) find_or_create_contact -> true['id'] -> TypeError ``` **Example Webhook Payload** ``` { "object": "instagram", "entry": [{ "time": 1764822592663, "id": "17841454414819988", "messaging": [{ "sender": { "id": "17841454414819988" }, // Business account "recipient": { "id": "1170166904857608" }, // User that can't be fetched "timestamp": 1764822591874, "message": { "attachments": [{ "type": "unsupported_type", "payload": { "url": "https://..." } }], "is_echo": true } }] }] } ``` **Corresponding Instagram API error:** ``` { "error": { "message": "The requested user cannot be found.", "type": "IGApiException", "code": 100, "error_subcode": 2534014 } } ``` **Debug Logs (Before Fix)** ``` [InstagramUserFetchError]: Unsupported get request. Object with ID '17841454414819988' does not exist... 100 [DEBUG] result: true [DEBUG] result.present?: true [DEBUG] find_or_create_contact called [DEBUG] user: true [DEBUG] Invalid user parameter - expected hash with id, got TrueClass: true ``` ### Solution ### 1\. Handle Error Code 100 Explicitly We now treat Instagram API error code **100** as a valid case for creating an “unknown” contact, similar to how we already handle error code `9010`: ``` # Handle error code 100: Object doesn't exist or missing permissions # This typically occurs when trying to fetch a user that doesn't exist # or has privacy restrictions. We can safely create an unknown contact. return unknown_user(ig_scope_id) if error_code == 100 ``` This ensures: * `fetch_instagram_user` returns a valid hash for unknown users. * `ensure_contact` can proceed safely without crashing. ### 2\. Prevent Exception Tracker Results from Leaking Through For any **unhandled** error codes, we now explicitly return an empty hash after logging the exception: ``` exception = StandardError.new( "#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})" ) ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception # Explicitly return empty hash for any unhandled error codes # This prevents the exception tracker result (true/false) from being returned. {} ``` This guarantees that `fetch_instagram_user` always returns either: * A valid user hash, * An “unknown” user hash * An empty hash Fixes https://linear.app/chatwoot/issue/CW-6068/typeerror-no-implicit-conversion-of-string-into-integer-typeerror --- app/services/instagram/message_text.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/services/instagram/message_text.rb b/app/services/instagram/message_text.rb index 3b371a2f4..3bdc569eb 100644 --- a/app/services/instagram/message_text.rb +++ b/app/services/instagram/message_text.rb @@ -59,11 +59,20 @@ class Instagram::MessageText < Instagram::BaseMessageText # We can safely create an unknown contact, making this integration work. return unknown_user(ig_scope_id) if error_code == 9010 + # Handle error code 100: Object doesn't exist or missing permissions + # This typically occurs when trying to fetch a user that doesn't exist or has privacy restrictions + # We can safely create an unknown contact, similar to error 9010 + return unknown_user(ig_scope_id) if error_code == 100 + Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id} ig_scope_id #{ig_scope_id}") Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}") exception = StandardError.new("#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})") ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception + + # Explicitly return empty hash for any unhandled error codes + # This prevents the exception tracker result from being returned + {} end def base_uri From 67dc21ea5fc6cda0102fe07a0aca39fb2a14466c Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:32:46 +0530 Subject: [PATCH 05/11] fix: Hardcoded 500 in AI api error response(#13005) ## Description Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires. Fixes false new relic alerts set due to hardcoding an error code ## Type of change Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Before image the 500 was hardcoded. RubyLLM doesn't send any error codes, so i removed the error code argument and just pass the error message Langfuse gets just the error message image local logs only show error image Better fix is to handle each case and show the user wherever necessary ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: aakashb95 --- lib/integrations/llm_base_service.rb | 2 +- lib/integrations/llm_instrumentation_helpers.rb | 4 +--- spec/lib/integrations/llm_instrumentation_spec.rb | 10 ++++------ 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/integrations/llm_base_service.rb b/lib/integrations/llm_base_service.rb index e8811121f..ca9459fc8 100644 --- a/lib/integrations/llm_base_service.rb +++ b/lib/integrations/llm_base_service.rb @@ -164,6 +164,6 @@ class Integrations::LlmBaseService end def build_error_response_from_exception(error, messages) - { error: error.message, error_code: 500, request_messages: messages } + { error: error.message, request_messages: messages } end end diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb index 1fc73f3f0..c03e3e9c7 100644 --- a/lib/integrations/llm_instrumentation_helpers.rb +++ b/lib/integrations/llm_instrumentation_helpers.rb @@ -32,10 +32,8 @@ module Integrations::LlmInstrumentationHelpers error = result[:error] || result['error'] return if error.blank? - error_code = result[:error_code] || result['error_code'] span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json) - span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code - span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}") + span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000)) end def set_metadata_attributes(span, params) diff --git a/spec/lib/integrations/llm_instrumentation_spec.rb b/spec/lib/integrations/llm_instrumentation_spec.rb index f567a91b7..97a45a414 100644 --- a/spec/lib/integrations/llm_instrumentation_spec.rb +++ b/spec/lib/integrations/llm_instrumentation_spec.rb @@ -200,17 +200,15 @@ RSpec.describe Integrations::LlmInstrumentation do result = instance.instrument_llm_call(params) do { - error: { message: 'API rate limit exceeded' }, - error_code: 'rate_limit_exceeded' + error: 'API rate limit exceeded' } end - expect(result[:error_code]).to eq('rate_limit_exceeded') + expect(result[:error]).to eq('API rate limit exceeded') expect(mock_span).to have_received(:set_attribute) - .with('gen_ai.response.error', '{"message":"API rate limit exceeded"}') - expect(mock_span).to have_received(:set_attribute).with('gen_ai.response.error_code', 'rate_limit_exceeded') + .with('gen_ai.response.error', '"API rate limit exceeded"') expect(mock_span).to have_received(:status=).with(mock_status) - expect(OpenTelemetry::Trace::Status).to have_received(:error).with('API Error: rate_limit_exceeded') + expect(OpenTelemetry::Trace::Status).to have_received(:error).with('API rate limit exceeded') end end From a971ff00f824e83686bdf04b1fd60062336f62f7 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 5 Dec 2025 10:52:13 +0530 Subject: [PATCH 06/11] fix: ruby_llm version conflicts with ai-agents (#13011) Co-authored-by: aakashb95 --- Gemfile | 4 ++-- Gemfile.lock | 11 +++++------ .../captain/tools/copilot/search_articles_service.rb | 10 +++------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/Gemfile b/Gemfile index f037583e7..46b11ef1d 100644 --- a/Gemfile +++ b/Gemfile @@ -191,10 +191,10 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.4.3' +gem 'ai-agents', '>= 0.7.0' # TODO: Move this gem as a dependency of ai-agents -gem 'ruby_llm', '>= 1.9.1' +gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm-schema' # OpenTelemetry for LLM observability diff --git a/Gemfile.lock b/Gemfile.lock index c3eec459b..55cfdef7e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,8 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.4.3) - ruby_llm (~> 1.3) + ai-agents (0.7.0) + ruby_llm (~> 1.8.2) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) @@ -819,7 +819,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.9.1) + ruby_llm (1.8.2) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -827,7 +827,6 @@ GEM faraday-net_http (>= 1) faraday-retry (>= 1) marcel (~> 1.0) - ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) ruby_llm-schema (0.2.5) ruby_parser (3.20.0) @@ -1018,7 +1017,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.4.3) + ai-agents (>= 0.7.0) annotate attr_extras audited (~> 5.4, >= 5.4.1) @@ -1120,7 +1119,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec ruby-openai - ruby_llm (>= 1.9.1) + ruby_llm (>= 1.8.2) ruby_llm-schema scout_apm scss_lint diff --git a/enterprise/app/services/captain/tools/copilot/search_articles_service.rb b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb index 24385a932..1647c939b 100644 --- a/enterprise/app/services/captain/tools/copilot/search_articles_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb @@ -3,13 +3,9 @@ class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseTool 'search_articles' end description 'Search articles based on parameters' - params do - string :query, description: 'Search articles by title or content (partial match)' - number :category_id, description: 'Filter articles by category ID' - any_of :status, description: 'Filter articles by status' do - string enum: %w[draft published archived] - end - end + param :query, desc: 'Search articles by title or content (partial match)', required: false + param :category_id, type: :number, desc: 'Filter articles by category ID', required: false + param :status, type: :string, desc: 'Filter articles by status - MUST BE ONE OF: draft, published, archived', required: false def execute(query: nil, category_id: nil, status: nil) articles = fetch_articles(query: query, category_id: category_id, status: status) From cc86b8c7f1dff9b47fcdfce76d4c21fc5e3b4149 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 5 Dec 2025 13:02:53 -0800 Subject: [PATCH 07/11] fix: stream attachment handling in workers (#12870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We’ve been watching Sidekiq workers climb from ~600 MB at boot to 1.4–1.5 GB after an hour whenever attachment-heavy jobs run. This PR is an experiment to curb that growth by streaming attachments instead of loading the whole blob into Ruby: reply-mailer inline attachments, Telegram uploads, and audio transcriptions now read/write in chunks. If this keeps RSS stable in production we’ll keep it; otherwise we’ll roll it back and keep digging --- .rubocop.yml | 7 +++ app/jobs/data_import_job.rb | 51 +++++++++++++------ ...ersation_reply_mailer_attachment_helper.rb | 51 +++++++++++++++++++ .../conversation_reply_mailer_helper.rb | 30 +---------- .../telegram/send_attachments_service.rb | 11 ++-- .../captain/llm/pdf_processing_service.rb | 12 +++-- .../messages/audio_transcription_service.rb | 36 ++++++++----- .../slack/send_on_slack_service.rb | 31 ++++++++--- rubocop/attachment_download.rb | 17 +++++++ .../llm/pdf_processing_service_spec.rb | 7 +-- spec/jobs/data_import_job_spec.rb | 9 +++- .../slack/send_on_slack_service_spec.rb | 15 ++++++ 12 files changed, 203 insertions(+), 74 deletions(-) create mode 100644 app/mailers/conversation_reply_mailer_attachment_helper.rb create mode 100644 rubocop/attachment_download.rb diff --git a/.rubocop.yml b/.rubocop.yml index 22c4220d9..d87f08bfd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ plugins: require: - ./rubocop/use_from_email.rb - ./rubocop/custom_cop_location.rb + - ./rubocop/attachment_download.rb - ./rubocop/one_class_per_file.rb Layout/LineLength: @@ -41,6 +42,12 @@ Style/SymbolArray: Style/OpenStructUse: Enabled: false +Chatwoot/AttachmentDownload: + Enabled: true + Exclude: + - 'spec/**/*' + - 'test/**/*' + Style/OptionalBooleanParameter: Exclude: - 'app/services/email_templates/db_resolver_service.rb' diff --git a/app/jobs/data_import_job.rb b/app/jobs/data_import_job.rb index 6146336fa..db1411e68 100644 --- a/app/jobs/data_import_job.rb +++ b/app/jobs/data_import_job.rb @@ -30,21 +30,15 @@ class DataImportJob < ApplicationJob def parse_csv_and_build_contacts contacts = [] rejected_contacts = [] - # Ensuring that importing non utf-8 characters will not throw error - data = @data_import.import_file.download - utf8_data = data.force_encoding('UTF-8') - # Ensure that the data is valid UTF-8, preserving valid characters - clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8') - - csv = CSV.parse(clean_data, headers: true) - - csv.each do |row| - current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access) - if current_contact.valid? - contacts << current_contact - else - append_rejected_contact(row, current_contact, rejected_contacts) + with_import_file do |file| + csv_reader(file).each do |row| + current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access) + if current_contact.valid? + contacts << current_contact + else + append_rejected_contact(row, current_contact, rejected_contacts) + end end end @@ -75,7 +69,7 @@ class DataImportJob < ApplicationJob end def generate_csv_data(rejected_contacts) - headers = CSV.parse(@data_import.import_file.download, headers: true).headers + headers = csv_headers headers << 'errors' return if rejected_contacts.blank? @@ -99,4 +93,31 @@ class DataImportJob < ApplicationJob def send_import_failed_notification_to_admin AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later end + + def csv_headers + header_row = nil + with_import_file do |file| + header_row = csv_reader(file).first + end + header_row&.headers || [] + end + + def csv_reader(file) + file.rewind + raw_data = file.read + utf8_data = raw_data.force_encoding('UTF-8') + clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8') + + CSV.new(StringIO.new(clean_data), headers: true) + end + + def with_import_file + temp_dir = Rails.root.join('tmp/imports') + FileUtils.mkdir_p(temp_dir) + + @data_import.import_file.open(tmpdir: temp_dir) do |file| + file.binmode + yield file + end + end end diff --git a/app/mailers/conversation_reply_mailer_attachment_helper.rb b/app/mailers/conversation_reply_mailer_attachment_helper.rb new file mode 100644 index 000000000..f4c0e2282 --- /dev/null +++ b/app/mailers/conversation_reply_mailer_attachment_helper.rb @@ -0,0 +1,51 @@ +# Handles attachment processing for ConversationReplyMailer flows. +module ConversationReplyMailerAttachmentHelper + private + + def process_attachments_as_files_for_email_reply + # Attachment processing for direct email replies (when replying to a single message) + # + # How attachments are handled: + # 1. Total file size (<20MB): Added directly to the email as proper attachments + # 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email + + @options[:attachments] = [] + @large_attachments = [] + current_total_size = 0 + + @message.attachments.each do |attachment| + current_total_size = handle_attachment_inline(current_total_size, attachment) + end + end + + def read_blob_content(blob) + buffer = +'' + blob.open do |file| + while (chunk = file.read(64.kilobytes)) + buffer << chunk + end + end + buffer + end + + def handle_attachment_inline(current_total_size, attachment) + blob = attachment.file.blob + return current_total_size if blob.blank? + + file_size = blob.byte_size + attachment_name = attachment.file.filename.to_s + + if current_total_size + file_size <= 20.megabytes + content = read_blob_content(blob) + mail.attachments[attachment_name] = { + mime_type: attachment.file.content_type || 'application/octet-stream', + content: content + } + @options[:attachments] << { name: attachment_name } + current_total_size + file_size + else + @large_attachments << attachment + current_total_size + end + end +end diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb index 2fe3695f9..cce236ec7 100644 --- a/app/mailers/conversation_reply_mailer_helper.rb +++ b/app/mailers/conversation_reply_mailer_helper.rb @@ -1,4 +1,6 @@ module ConversationReplyMailerHelper + include ConversationReplyMailerAttachmentHelper + def prepare_mail(cc_bcc_enabled) @options = { to: to_emails, @@ -27,34 +29,6 @@ module ConversationReplyMailerHelper mail(@options) end - def process_attachments_as_files_for_email_reply - # Attachment processing for direct email replies (when replying to a single message) - # - # How attachments are handled: - # 1. Total file size (<20MB): Added directly to the email as proper attachments - # 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email - - @options[:attachments] = [] - @large_attachments = [] - current_total_size = 0 - - @message.attachments.each do |attachment| - raw_data = attachment.file.download - attachment_name = attachment.file.filename.to_s - file_size = raw_data.bytesize - - # Attach files directly until we hit 20MB total - # After reaching 20MB, send remaining files as links - if current_total_size + file_size <= 20.megabytes - mail.attachments[attachment_name] = raw_data - @options[:attachments] << { name: attachment_name } - current_total_size += file_size - else - @large_attachments << attachment - end - end - end - private def oauth_smtp_settings diff --git a/app/services/telegram/send_attachments_service.rb b/app/services/telegram/send_attachments_service.rb index e214fe78f..5ba8fd4ae 100644 --- a/app/services/telegram/send_attachments_service.rb +++ b/app/services/telegram/send_attachments_service.rb @@ -96,11 +96,16 @@ class Telegram::SendAttachmentsService # Telegram picks up the file name from original field name, so we need to save the file with the original name. # Hence not using Tempfile here. def save_attachment_to_tempfile(attachment) - raw_data = attachment.file.download - temp_dir = Rails.root.join('tmp/uploads') + temp_dir = Rails.root.join('tmp/uploads', "telegram-#{attachment.message_id}") FileUtils.mkdir_p(temp_dir) temp_file_path = File.join(temp_dir, attachment.file.filename.to_s) - File.write(temp_file_path, raw_data, mode: 'wb') + + File.open(temp_file_path, 'wb') do |file| + attachment.file.blob.open do |blob_file| + IO.copy_stream(blob_file, file) + end + end + temp_file_path end diff --git a/enterprise/app/services/captain/llm/pdf_processing_service.rb b/enterprise/app/services/captain/llm/pdf_processing_service.rb index 3ab616577..55177b78d 100644 --- a/enterprise/app/services/captain/llm/pdf_processing_service.rb +++ b/enterprise/app/services/captain/llm/pdf_processing_service.rb @@ -29,12 +29,16 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService end end - def with_tempfile(&) + def with_tempfile Tempfile.create(['pdf_upload', '.pdf'], binmode: true) do |temp_file| - temp_file.write(document.pdf_file.download) - temp_file.close + document.pdf_file.blob.open do |blob_file| + IO.copy_stream(blob_file, temp_file) + end - File.open(temp_file.path, 'rb', &) + temp_file.flush + temp_file.rewind + + yield temp_file end end end diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index a200c1496..d3cc91376 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -27,10 +27,16 @@ class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService end def fetch_audio_file - temp_dir = Rails.root.join('tmp/uploads') + temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions') FileUtils.mkdir_p(temp_dir) - temp_file_path = File.join(temp_dir, attachment.file.filename.to_s) - File.write(temp_file_path, attachment.file.download, mode: 'wb') + temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}") + + File.open(temp_file_path, 'wb') do |file| + attachment.file.blob.open do |blob_file| + IO.copy_stream(blob_file, file) + end + end + temp_file_path end @@ -40,18 +46,24 @@ class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService temp_file_path = fetch_audio_file - response = @client.audio.transcribe( - parameters: { - model: 'whisper-1', - file: File.open(temp_file_path), - temperature: 0.4 - } - ) + response_text = nil + + File.open(temp_file_path, 'rb') do |file| + response = @client.audio.transcribe( + parameters: { + model: 'whisper-1', + file: file, + temperature: 0.4 + } + ) + + response_text = response['text'] + end FileUtils.rm_f(temp_file_path) - update_transcription(response['text']) - response['text'] + update_transcription(response_text) + response_text end def update_transcription(transcribed_text) diff --git a/lib/integrations/slack/send_on_slack_service.rb b/lib/integrations/slack/send_on_slack_service.rb index 46c111a83..4e959c469 100644 --- a/lib/integrations/slack/send_on_slack_service.rb +++ b/lib/integrations/slack/send_on_slack_service.rb @@ -121,8 +121,6 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService end def upload_files - return unless message.attachments.any? - files = build_files_array return if files.empty? @@ -136,6 +134,8 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService Rails.logger.info "slack_upload_result: #{result}" rescue Slack::Web::Api::Errors::SlackError => e Rails.logger.error "Failed to upload files: #{e.message}" + ensure + files.each { |file| file[:content]&.clear } end end @@ -143,14 +143,31 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService message.attachments.filter_map do |attachment| next unless attachment.with_attached_file? - { - filename: attachment.file.filename.to_s, - content: attachment.file.download, - title: attachment.file.filename.to_s - } + build_file_payload(attachment) end end + def build_file_payload(attachment) + content = download_attachment_content(attachment) + return if content.blank? + + { + filename: attachment.file.filename.to_s, + content: content, + title: attachment.file.filename.to_s + } + end + + def download_attachment_content(attachment) + buffer = +'' + attachment.file.blob.open do |file| + while (chunk = file.read(64.kilobytes)) + buffer << chunk + end + end + buffer + end + def sender_name(sender) sender.try(:name) ? "#{sender.try(:name)} (#{sender_type(sender)})" : sender_type(sender) end diff --git a/rubocop/attachment_download.rb b/rubocop/attachment_download.rb new file mode 100644 index 000000000..14c56203a --- /dev/null +++ b/rubocop/attachment_download.rb @@ -0,0 +1,17 @@ +require 'rubocop' + +module RuboCop::Cop::Chatwoot; end + +class RuboCop::Cop::Chatwoot::AttachmentDownload < RuboCop::Cop::Base + MSG = 'Avoid calling `.file/.blob.download`; use `blob.open` or streaming IO instead.'.freeze + + def_node_matcher :unsafe_download?, <<~PATTERN + (send (send _ {:file :blob}) :download ...) + PATTERN + + def on_send(node) + return unless unsafe_download?(node) + + add_offense(node.loc.selector, message: MSG) + end +end diff --git a/spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb b/spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb index 9dc416685..fd519dcdd 100644 --- a/spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb +++ b/spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb @@ -28,13 +28,14 @@ RSpec.describe Captain::Llm::PdfProcessingService do context 'when uploading PDF to OpenAI' do let(:mock_client) { instance_double(OpenAI::Client) } let(:pdf_content) { 'PDF content' } + let(:blob_double) { instance_double(ActiveStorage::Blob) } + let(:pdf_file) { instance_double(ActiveStorage::Attachment) } before do allow(document).to receive(:openai_file_id).and_return(nil) - - # Use a simple double for ActiveStorage since it's a complex Rails object - pdf_file = double('pdf_file', download: pdf_content) # rubocop:disable RSpec/VerifiedDoubles allow(document).to receive(:pdf_file).and_return(pdf_file) + allow(pdf_file).to receive(:blob).and_return(blob_double) + allow(blob_double).to receive(:open).and_yield(StringIO.new(pdf_content)) allow(OpenAI::Client).to receive(:new).and_return(mock_client) # Use a simple double for OpenAI::Files as it may not be loaded diff --git a/spec/jobs/data_import_job_spec.rb b/spec/jobs/data_import_job_spec.rb index fb1057f4a..19178b177 100644 --- a/spec/jobs/data_import_job_spec.rb +++ b/spec/jobs/data_import_job_spec.rb @@ -15,8 +15,11 @@ RSpec.describe DataImportJob do describe 'retrying the job' do context 'when ActiveStorage::FileNotFoundError is raised' do + let(:import_file_double) { instance_double(ActiveStorage::Blob) } + before do - allow(data_import.import_file).to receive(:download).and_raise(ActiveStorage::FileNotFoundError) + allow(data_import).to receive(:import_file).and_return(import_file_double) + allow(import_file_double).to receive(:open).and_raise(ActiveStorage::FileNotFoundError) end it 'retries the job' do @@ -158,7 +161,9 @@ RSpec.describe DataImportJob do end before do - allow(data_import.import_file).to receive(:download).and_return(invalid_csv_content) + import_file_double = instance_double(ActiveStorage::Blob) + allow(data_import).to receive(:import_file).and_return(import_file_double) + allow(import_file_double).to receive(:open).and_yield(StringIO.new(invalid_csv_content)) end it 'does not import any data and handles the MalformedCSVError' do diff --git a/spec/lib/integrations/slack/send_on_slack_service_spec.rb b/spec/lib/integrations/slack/send_on_slack_service_spec.rb index 87d58b79b..5d75a8c2b 100644 --- a/spec/lib/integrations/slack/send_on_slack_service_spec.rb +++ b/spec/lib/integrations/slack/send_on_slack_service_spec.rb @@ -206,6 +206,21 @@ describe Integrations::Slack::SendOnSlackService do expect(message.attachments.count).to eq 2 end + it 'streams attachment blobs and uploads only once' do + expect(slack_client).to receive(:chat_postMessage).and_return(slack_message) + + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + blob = attachment.file.blob + allow(blob).to receive(:open).and_call_original + + expect(blob).to receive(:open).and_call_original + expect(slack_client).to receive(:files_upload_v2).once.and_return(file_attachment) + + message.save! + builder.perform + end + it 'handles file upload errors gracefully' do expect(slack_client).to receive(:chat_postMessage).with( channel: hook.reference_id, From eb759255d87d489decfefed37bb483fb4726fc91 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:52:17 +0530 Subject: [PATCH 08/11] perf: update the logic to purchase credits (#12998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - Replaces Stripe Checkout session flow with direct card charging for AI credit top-ups - Adds a two-step confirmation modal (select package → confirm purchase) for better UX - Creates Stripe invoice directly and charges the customer's default payment method immediately ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Using the specs - UI manual test cases image image ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra --- .../api/enterprise/specs/account.spec.js | 26 +++ .../dashboard/i18n/locale/en/settings.json | 10 +- .../dashboard/settings/billing/Index.vue | 10 +- .../components/PurchaseCreditsModal.vue | 184 +++++++++++++----- config/locales/en.yml | 2 + config/routes.rb | 1 - .../enterprise/api/v1/accounts_controller.rb | 13 +- .../billing/handle_stripe_event_service.rb | 27 --- .../billing/topup_checkout_service.rb | 106 +++++----- .../billing/topup_fulfillment_service.rb | 2 + .../api/v1/accounts_controller_spec.rb | 72 +++++++ .../billing/topup_checkout_service_spec.rb | 60 ++++++ .../billing/topup_fulfillment_service_spec.rb | 36 ++++ 13 files changed, 414 insertions(+), 135 deletions(-) create mode 100644 spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/topup_fulfillment_service_spec.rb diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 9c65b0b67..47d2eb26d 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => { expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('checkout'); expect(accountAPI).toHaveProperty('toggleDeletion'); + expect(accountAPI).toHaveProperty('createTopupCheckout'); + expect(accountAPI).toHaveProperty('getLimits'); }); describe('API calls', () => { @@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => { { action_type: 'undelete' } ); }); + + it('#createTopupCheckout with credits', () => { + accountAPI.createTopupCheckout(1000); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits: 1000 } + ); + }); + + it('#createTopupCheckout with different credit amounts', () => { + const creditAmounts = [1000, 2500, 6000, 12000]; + creditAmounts.forEach(credits => { + accountAPI.createTopupCheckout(credits); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits } + ); + }); + }); + + it('#getLimits', () => { + accountAPI.getLimits(); + expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits'); + }); }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 64be235a2..4e51ef37a 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -422,7 +422,15 @@ "PURCHASE": "Purchase Credits", "LOADING": "Loading options...", "FETCH_ERROR": "Failed to load credit options. Please try again.", - "PURCHASE_ERROR": "Failed to process purchase. Please try again." + "PURCHASE_ERROR": "Failed to process purchase. Please try again.", + "PURCHASE_SUCCESS": "Successfully added {credits} credits to your account", + "CONFIRM": { + "TITLE": "Confirm Purchase", + "DESCRIPTION": "You are about to purchase {credits} credits for {amount}.", + "INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.", + "GO_BACK": "Go Back", + "CONFIRM_PURCHASE": "Confirm Purchase" + } } }, "SECURITY_SETTINGS": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue index 4f0981967..bcfa46193 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -132,6 +132,11 @@ const openPurchaseCreditsModal = () => { purchaseCreditsModalRef.value?.open(); }; +const handleTopupSuccess = () => { + // Refresh limits to show updated credit balance + fetchLimits(); +}; + onMounted(handleBillingPageLogic); @@ -254,7 +259,10 @@ onMounted(handleBillingPageLogic); - + diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue index ebbab0aa8..33f299b9b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue @@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import CreditPackageCard from './CreditPackageCard.vue'; import EnterpriseAccountAPI from 'dashboard/api/enterprise/account'; -const emit = defineEmits(['close']); +const emit = defineEmits(['close', 'success']); const { t } = useI18n(); @@ -19,19 +19,78 @@ const TOPUP_OPTIONS = [ ]; const POPULAR_CREDITS_AMOUNT = 6000; +const STEP_SELECT = 'select'; +const STEP_CONFIRM = 'confirm'; const dialogRef = ref(null); const selectedCredits = ref(null); const isLoading = ref(false); +const currentStep = ref(STEP_SELECT); const selectedOption = computed(() => { return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value); }); +const formattedAmount = computed(() => { + if (!selectedOption.value) return ''; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: selectedOption.value.currency.toUpperCase(), + }).format(selectedOption.value.amount); +}); + +const formattedCredits = computed(() => { + if (!selectedOption.value) return ''; + return selectedOption.value.credits.toLocaleString(); +}); + +const dialogTitle = computed(() => { + return currentStep.value === STEP_SELECT + ? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE') + : t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE'); +}); + +const dialogDescription = computed(() => { + return currentStep.value === STEP_SELECT + ? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION') + : ''; +}); + +const dialogWidth = computed(() => { + return currentStep.value === STEP_SELECT ? 'xl' : 'md'; +}); + const handlePackageSelect = credits => { selectedCredits.value = credits; }; +const open = () => { + const popularOption = TOPUP_OPTIONS.find( + o => o.credits === POPULAR_CREDITS_AMOUNT + ); + selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits; + currentStep.value = STEP_SELECT; + isLoading.value = false; + dialogRef.value?.open(); +}; + +const close = () => { + dialogRef.value?.close(); +}; + +const handleClose = () => { + emit('close'); +}; + +const goToConfirmStep = () => { + if (!selectedOption.value) return; + currentStep.value = STEP_CONFIRM; +}; + +const goBackToSelectStep = () => { + currentStep.value = STEP_SELECT; +}; + const handlePurchase = async () => { if (!selectedOption.value) return; @@ -40,9 +99,14 @@ const handlePurchase = async () => { const response = await EnterpriseAccountAPI.createTopupCheckout( selectedOption.value.credits ); - if (response.data.redirect_url) { - window.location.href = response.data.redirect_url; - } + + close(); + emit('success', response.data); + useAlert( + t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', { + credits: response.data.credits, + }) + ); } catch (error) { const errorMessage = error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR'); @@ -52,61 +116,71 @@ const handlePurchase = async () => { } }; -const handleClose = () => { - emit('close'); -}; - -const open = () => { - // Pre-select the most popular option - const popularOption = TOPUP_OPTIONS.find( - o => o.credits === POPULAR_CREDITS_AMOUNT - ); - selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits; - dialogRef.value?.open(); -}; - -const close = () => { - dialogRef.value?.close(); -}; - defineExpose({ open, close });