diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index cc4c68f2e..000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 12, - "sourceType": "module" - }, - "plugins": ["@stylistic", "@typescript-eslint"], - "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"], - "rules": { - "eqeqeq": ["error", "smart"], - "@stylistic/indent": "error", - "@stylistic/quotes": ["error", "single"], - "@stylistic/arrow-parens": "error", - "@stylistic/arrow-spacing": "error", - "@stylistic/brace-style": "error", - "@stylistic/computed-property-spacing": ["error", "never"], - "@stylistic/jsx-quotes": ["error", "prefer-single"], - "@stylistic/keyword-spacing": [ - "error", - { - "before": true - } - ], - "@stylistic/semi": "error", - "@stylistic/space-before-function-paren": "error", - "@stylistic/space-infix-ops": "error", - "@stylistic/space-unary-ops": "error", - "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/consistent-type-definitions": ["error", "type"], - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-explicit-any": "off", - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "error", - "react/prop-types": "off" - }, - "env": { - "browser": true, - "es2021": true - }, - "ignorePatterns": ["esbuild.js"] -} diff --git a/.github/workflows/code.end-to-end-test.nightly.yml b/.github/workflows/code.end-to-end-test.nightly.yml index ea633aa38..1922b1622 100644 --- a/.github/workflows/code.end-to-end-test.nightly.yml +++ b/.github/workflows/code.end-to-end-test.nightly.yml @@ -40,7 +40,10 @@ jobs: run: npm ci - name: Run Cypress E2E Suite env: - TEST_ACCOUNT_PASSWORD: ${{ secrets.TEST_ACCOUNT_PASSWORD }} + ADMIN_USER_NAME: ${{ secrets.ADMIN_USER_NAME }} + ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }} + USER_NAME: ${{ secrets.USER_NAME }} + USER_PASSWORD: ${{ secrets.USER_PASSWORD }} run: npx cypress run --config-file cypress/cypress.e2e.config.ts - name: Archive Cypress videos & screenshots if: failure() || always() @@ -48,8 +51,8 @@ jobs: with: name: cypress-e2e-artifacts path: | - cypress/e2e/videos - cypress/e2e/screenshots + cypress/videos/e2e + cypress/screenshots/e2e notify_e2e_end: name: πŸ”” E2E Tests Finished diff --git a/.gitignore b/.gitignore index a1b67bf94..a4c735062 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ config-custom.yaml # Test Artifacts /cypress/videos /cypress/screenshots +/cypress/logs + +# Cypress local environment +/cypress/.env.local diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1007833ae..9b1982fe6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -116,7 +116,8 @@ repos: files: \.[jt]sx?$ types: [file] args: - - --max-warnings=10 + - --max-warnings=20 + - --no-warn-ignored - --fix # - repo: https://github.com/Lucas-C/pre-commit-hooks-safety diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b507d1c..ebf7e66ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +# v6.1.1 + +## UI Cleanup +### [Light/Dark Theme Fixes] +Some UI elements in light mode were being rendered in a dark theme, such as code blocks, Mermaid tables, message metadata, etc. This change ensures elements render in their appropriate theme based on the user's selection. + +### [Auto-Scrolling Fix] +Allows users to break out of auto-scrolling during streamed responses by scrolling away from the bottom of the screen. Users can reset auto-scrolling by scrolling back down to the bottom of the stream. + +### [API Token Dashboard Metrics] +This change ensures that users who programmatically interact with LISA are captured in the metrics dashboard. Additionally, it introduces a reorganization of the metrics dashboard to enhance readability. + +### [Cypress Smoke Test Refactor] +Significantly enhanced the smoke tests to be more reliable. Added new E2E tests that reuse the smoke tests and a new model creation workflow E2E test. + +### [Session History Reload Fix] +Fixed session history not loading when selected, and updated session hooks to correctly invalidate and retrieve the session after selecting a new session. + +### [Markdown Table Support] +Introduced GitHub Flavored Markdown (GFM) to support markdown tables. Added Tailwind CSS overrides to render markdown in chat prompts. Enhanced the system prompt to render math expressions without additional prompting. + + +## Key Changes +- **Documentation**: Added access control details to the Getting Started section along with general updates, added instructions on how to accept the self-signed certificate in the browser, and updates to configuration labels. +- **Cypress Tests**: Added additional smoke tests to ensure admin pages load with data, chat prompts render responses, chat sessions are selectable and load properly, and non-admins can't navigate to admin pages. +- **Administrative**: Added an `AdminRoute` wrapper around the `McpWorkbench` component. + +## Acknowledgements +* @bedanley +* @estohlmann +* @jmharold + +**Full Changelog**: https://github.com/awslabs/LISA/compare/v6.1.0..v6.1.1 + # v6.1.0 ## ⚠️ Important: Major Dependency Updates diff --git a/Makefile b/Makefile index 3c18123bf..ea53341d1 100644 --- a/Makefile +++ b/Makefile @@ -277,7 +277,7 @@ define print_config endef ## Deploy all infrastructure -deploy: installPythonRequirements dockerCheck dockerLogin cleanMisc modelCheck buildNpmModules +deploy: install dockerCheck dockerLogin cleanMisc modelCheck buildNpmModules $(call print_config) ifeq ($(HEADLESS),true) npx cdk deploy ${STACK} $(if $(PROFILE),--profile ${PROFILE}) --require-approval never -c ${ENV}='$(shell echo '${${ENV}}')'; diff --git a/VERSION b/VERSION index dfda3e0b4..f3b5af39e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0 +6.1.1 diff --git a/cypress/.env.local.example b/cypress/.env.local.example new file mode 100644 index 000000000..3bceaf84b --- /dev/null +++ b/cypress/.env.local.example @@ -0,0 +1,14 @@ +# Cypress E2E Test Configuration +# Copy this file to .env.local and update with your local values +# .env.local is gitignored and will not be committed + +# Base URL for your local instance +BASE_URL=http://localhost:8080 + +# Admin test account credentials +ADMIN_USER_NAME=admin@example.com +ADMIN_PASSWORD=your-admin-password-here + +# Non-admin test account credentials +USER_NAME=testuser@example.com +USER_PASSWORD=your-user-password-here diff --git a/cypress/README.md b/cypress/README.md index bcdf0de67..c53b6d0e7 100644 --- a/cypress/README.md +++ b/cypress/README.md @@ -54,7 +54,6 @@ DevTools listening on ws://127.0.0.1:51352/devtools/browser/2f804c68-414e-4004-9 β”‚ Node Version: v18.20.4 (/.../.nvm/versions/node/v18.20.4/bin/node) β”‚ β”‚ Specs: 1 found (administration.e2e.spec.ts) β”‚ β”‚ Searched: src/e2e/specs/**/*.e2e.spec.ts β”‚ - β”‚ Experiments: experimentalStudio=true β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cypress/cypress.e2e.config.ts b/cypress/cypress.e2e.config.ts index 7539dbc06..6ce1bf660 100644 --- a/cypress/cypress.e2e.config.ts +++ b/cypress/cypress.e2e.config.ts @@ -17,10 +17,18 @@ /// import { defineConfig } from 'cypress'; +import * as dotenv from 'dotenv'; +import * as fs from 'fs'; import path from 'path'; const PROJECT_ROOT = path.resolve(__dirname); +// Load local environment file if it exists +const envPath = path.join(PROJECT_ROOT, '.env.local'); +if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); +} + export default defineConfig({ video: true, // turn on video recording videoCompression: true, @@ -28,16 +36,22 @@ export default defineConfig({ screenshotOnRunFailure: true, // auto‑snap on any test failure screenshotsFolder: `${PROJECT_ROOT}/screenshots/e2e`, trashAssetsBeforeRuns: true, // wipe out old videos/screenshots + defaultCommandTimeout: 30000, // 30 seconds for real API calls + requestTimeout: 30000, // 30 seconds for API requests + responseTimeout: 30000, // 30 seconds for API responses + chromeWebSecurity: false, // Disable web security to allow cross-origin requests e2e: { specPattern: `${PROJECT_ROOT}/src/e2e/specs/**/*.e2e.spec.ts`, supportFile: `${PROJECT_ROOT}/src/e2e/support/index.ts`, - experimentalStudio: true, fixturesFolder: `${PROJECT_ROOT}/src/e2e/fixtures`, setupNodeEvents () { }, - baseUrl: 'https://5bma74uv9c.execute-api.us-east-1.amazonaws.com/dev', + baseUrl: process.env.BASE_URL || 'https://chat.dev.lisa.aiml-adc.aws.dev', env: { - TEST_ACCOUNT_PASSWORD: process.env.TEST_ACCOUNT_PASSWORD, + ADMIN_USER_NAME: process.env.ADMIN_USER_NAME, + ADMIN_PASSWORD: process.env.ADMIN_PASSWORD, + USER_NAME: process.env.USER_NAME, + USER_PASSWORD: process.env.USER_PASSWORD, }, }, }); diff --git a/cypress/cypress.smoke.config.ts b/cypress/cypress.smoke.config.ts index 7ed5e3e0e..463a7cd9d 100644 --- a/cypress/cypress.smoke.config.ts +++ b/cypress/cypress.smoke.config.ts @@ -30,7 +30,6 @@ export default defineConfig({ e2e: { specPattern: `${PROJECT_ROOT}/src/smoke/specs/**/*.smoke.spec.ts`, supportFile: `${PROJECT_ROOT}/src/smoke/support/index.ts`, - experimentalStudio: true, fixturesFolder: `${PROJECT_ROOT}/src/smoke/fixtures`, setupNodeEvents () { }, diff --git a/cypress/package.json b/cypress/package.json index d80a08f64..28ad09115 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -5,6 +5,7 @@ "devDependencies": { "@types/node": "^24.10.2", "cypress": "^15.7.1", + "dotenv": "^17.2.3", "lint-staged": "^16.2.7", "lodash": "^4.17.21" }, diff --git a/cypress/src/smoke/specs/administration.smoke.spec.ts b/cypress/src/e2e/specs/admin.e2e.spec.ts similarity index 51% rename from cypress/src/smoke/specs/administration.smoke.spec.ts rename to cypress/src/e2e/specs/admin.e2e.spec.ts index 0d854b49b..8ecb6383f 100644 --- a/cypress/src/smoke/specs/administration.smoke.spec.ts +++ b/cypress/src/e2e/specs/admin.e2e.spec.ts @@ -17,31 +17,22 @@ /// /** - * E2E suite for Administration features: - * - Ensures admin users can view and interact with the Administration menu - * - Verifies correct menu items and expansion behavior - * - Confirms non-admin users do not see the Administration option + * E2E suite for Admin Navigation features. + * Uses shared test suite against real deployment. */ +import { runAdminTests } from '../../shared/specs/admin.shared.spec'; -import { - checkAdminButtonExists, - expandAdminMenu, - checkNoAdminButton, -} from '../../support/adminHelpers'; - -describe('Administration features (Smoke)', () => { - it('Admin sees the button', () => { - cy.loginAs('admin'); - checkAdminButtonExists(); +describe('Admin Navigation (E2E)', () => { + before(() => { + cy.clearAllSessionStorage(); }); - it('Admin can expand menu', () => { + beforeEach(() => { cy.loginAs('admin'); - expandAdminMenu(); }); - it('Non-admin does not see the button', () => { - cy.loginAs('user'); - checkNoAdminButton(); + runAdminTests({ + expectMinItems: false, + verifyFixtureData: false, }); }); diff --git a/cypress/src/e2e/specs/bedrock-model-workflow.e2e.spec.ts b/cypress/src/e2e/specs/bedrock-model-workflow.e2e.spec.ts new file mode 100644 index 000000000..25b795189 --- /dev/null +++ b/cypress/src/e2e/specs/bedrock-model-workflow.e2e.spec.ts @@ -0,0 +1,36 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * E2E test for Bedrock model creation and chat workflow. + * Creates a Bedrock model, then uses it in chat. + */ + +import { runBedrockModelWorkflowTests } from '../../shared/specs/bedrock-model-workflow.shared.spec'; + +describe('Bedrock Model Workflow (E2E)', () => { + before(() => { + cy.clearAllSessionStorage(); + }); + + beforeEach(() => { + cy.loginAs('admin'); + }); + + runBedrockModelWorkflowTests(); +}); diff --git a/cypress/src/e2e/specs/chat.e2e.spec.ts b/cypress/src/e2e/specs/chat.e2e.spec.ts new file mode 100644 index 000000000..7aa20aecb --- /dev/null +++ b/cypress/src/e2e/specs/chat.e2e.spec.ts @@ -0,0 +1,37 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * E2E suite for Chat Page features. + * Uses shared test suite against real deployment. + */ +import { runChatTests } from '../../shared/specs/chat.shared.spec'; + +describe('Chat Page (E2E)', () => { + before(() => { + cy.clearAllSessionStorage(); + }); + + beforeEach(() => { + cy.loginAs('user'); + }); + + runChatTests({ + verifyFixtureData: false, + }); +}); diff --git a/cypress/src/e2e/specs/user.e2e.spec.ts b/cypress/src/e2e/specs/user.e2e.spec.ts new file mode 100644 index 000000000..699929d92 --- /dev/null +++ b/cypress/src/e2e/specs/user.e2e.spec.ts @@ -0,0 +1,37 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * E2E suite for User role features. + * Uses shared test suite against real deployment. + */ +import { runUserTests } from '../../shared/specs/user.shared.spec'; + +describe('User features (E2E)', () => { + before(() => { + cy.clearAllSessionStorage(); + }); + + beforeEach(() => { + cy.loginAs('user'); + }); + + runUserTests({ + verifyFixtureData: false, + }); +}); diff --git a/cypress/src/e2e/support/commands.ts b/cypress/src/e2e/support/commands.ts index 054e233f6..6ec87d14d 100644 --- a/cypress/src/e2e/support/commands.ts +++ b/cypress/src/e2e/support/commands.ts @@ -16,87 +16,177 @@ /// -// Base application URL from Cypress config import { getTopLevelDomain } from './utils'; const BASE_URL = Cypress.config('baseUrl') as string; +const APIS = [ + { pattern: '**/configuration*', alias: 'getConfiguration', critical: true }, + { pattern: '**/models*', alias: 'getModels', chat: true }, + { pattern: '**/session*', alias: 'getSessions', chat: true }, + { pattern: '**/api-tokens*', alias: 'getApiTokens' }, + { pattern: '**/repository*', alias: 'getRepositories', chat: true }, + { pattern: '**/collection*', alias: 'getCollections' }, + { pattern: '**/mcp*', alias: 'getMcp' }, + { pattern: '**/mcp-management*', alias: 'getMcpServers' }, + { pattern: '**/mcp-workbench*', alias: 'getMcpWorkbench' }, + { pattern: '**/prompt-templates*', alias: 'getPromptTemplates' }, + { pattern: '**/user-preferences*', alias: 'getUserPreferences' }, +]; + +/** + * Setup intercepts for critical API calls. + * Call this before visiting the app. + */ +function setupApiIntercepts () { + APIS.forEach(({ pattern, alias }) => { + cy.intercept('GET', pattern).as(alias); + }); +} + +/** + * Wait for all critical API calls to complete. + */ +function waitForCriticalApis () { + const aliases = APIS.filter(({ critical }) => critical) + .map(({ alias }) => `@${alias}`); + cy.wait(aliases, { timeout: 30000 }); +} + +/** + * Wait for the app to be fully loaded after authentication. + */ +function waitForAppReady () { + // Wait for "Loading configuration..." to disappear + cy.contains('Loading configuration...', { timeout: 15000 }).should('not.exist'); + + // Wait for any loading spinners to complete + cy.get('body').then(($body) => { + if ($body.find('[class*="awsui_spinner"]').length > 0) { + cy.get('[class*="awsui_spinner"]', { timeout: 10000 }).should('not.exist'); + } + }); + + // Wait for header to be visible (indicates app is ready) + cy.get('header', { timeout: 15000 }).should('be.visible'); +} + /** - * Custom command to log in a user via stubbed OAuth2/OIDC. - * Can log in as an 'admin' or a normal 'user'. + * Custom command to log in a user via Cognito OAuth2/OIDC. + * Uses cy.session() for caching and role-specific credentials. * - * @param {'admin'|'user'} username - The username to simulate (defaults to 'user'). + * @param {'admin'|'user'} role - The role to log in as (defaults to 'user'). */ -Cypress.Commands.add('loginAs', (username = 'user') => { +Cypress.Commands.add('loginAs', (role = 'user') => { const log = Cypress.log({ displayName: 'Cognito Login', - message: [`πŸ” Authenticating | ${username}`], + message: [`πŸ” Authenticating | ${role}`], autoEnd: false, }); - let cognitoOathEndpoint = ''; - let cognitoOathClientName = ''; - let cognitoAuthEndpoint = ''; + log.snapshot('before'); - // Temporarily suppress exceptions. We expect to get 401's which will trigger the login redirect - cy.on('uncaught:exception', () => { - return false; - }); + + // Temporarily suppress exceptions during login flow + cy.on('uncaught:exception', () => false); + cy.session( - `cognito-${username}`, + `cognito-${role}`, () => { - // Handle cognito portal information cy.request(BASE_URL + '/env.js').then((resp) => { const OIDC_URL_REGEX = /["']?AUTHORITY['"]?:\s*['"]?([A-Za-z:\-._/0-9]+)['"]?/; - const OIDC_APP_NAME_REGEX = /["']?CLIENT_ID['"]?:\s*['"]?([A-Za-z:\-._/0-9]+)['"]?/; + const oidcUrlMatches = OIDC_URL_REGEX.exec(resp.body); - if (oidcUrlMatches && oidcUrlMatches.length === 2) { - cognitoOathEndpoint = oidcUrlMatches[1]; - } - const oidcClientNameMatches = OIDC_APP_NAME_REGEX.exec(resp.body); - if (oidcClientNameMatches && oidcClientNameMatches.length === 2) { - cognitoOathClientName = oidcClientNameMatches[1]; - } + const cognitoOathEndpoint = oidcUrlMatches?.[1] || ''; + cy.request(`${cognitoOathEndpoint}/.well-known/openid-configuration`).then((oathResponse) => { - cognitoAuthEndpoint = getTopLevelDomain(oathResponse.body.authorization_endpoint); - // click the sign in link + const cognitoAuthEndpoint = getTopLevelDomain(oathResponse.body.authorization_endpoint); + + // Start the login flow cy.visit(BASE_URL); cy.contains('button', 'Sign in').click(); - cy.origin(cognitoAuthEndpoint, { args: username }, (username: string) => { - cy.on('uncaught:exception', () => { - return false; - }); - // This is a lot of overhead to put in username, but there are intermittent results while waiting - // for the DOM to stabilize after the redirect and this way is more foolproof + + // Perform login on Cognito hosted UI + cy.origin(cognitoAuthEndpoint, { args: role }, (userRole: string) => { + cy.on('uncaught:exception', () => false); + + // Get credentials based on role + const username = userRole === 'admin' + ? Cypress.env('ADMIN_USER_NAME') + : Cypress.env('USER_NAME'); + const password = userRole === 'admin' + ? Cypress.env('ADMIN_PASSWORD') + : Cypress.env('USER_PASSWORD'); + + // Wait for username field and fill it cy.get('input[name="username"]', { timeout: 10000 }) .filter(':visible') .first() - .as('usernameInput') - .then(() => { - // click may re‑render; re‑query afterwards - cy.get('@usernameInput').click({ force: true }); - }) - .then(() => { - cy.get('@usernameInput').clear({ force: true }); - cy.get('@usernameInput').type(username, { force: true }); - }); - cy.get('input[name="password"]').filter(':visible').type(Cypress.env('TEST_ACCOUNT_PASSWORD'), { force: true }); - cy.get('input[aria-label="submit"]').filter(':visible').click({ force: true }); - }, - ); + .as('usernameInput'); + cy.get('@usernameInput').click({ force: true }); + cy.get('@usernameInput').clear({ force: true }); + cy.get('@usernameInput').type(username, { force: true }); + + // Fill password + cy.get('input[name="password"]') + .filter(':visible') + .type(password, { force: true, log: false }); + + // Submit + cy.get('input[type="submit"], input[aria-label="submit"], button[type="submit"]') + .filter(':visible') + .first() + .click({ force: true }); + }); + + // Wait for redirect back to app + cy.wait(2000); }); - cy.wait(2000); }); }, { validate: () => { - cy.wrap(sessionStorage) - .invoke('getItem', `oidc.user:${cognitoOathEndpoint}:${cognitoOathClientName}`) - .should('exist'); - + // Check that we have an OIDC token in sessionStorage + // The key format is: oidc.user:: + // We check for any key starting with 'oidc.user:' since we don't have the exact values here + cy.window().then((win) => { + const hasOidcToken = Object.keys(win.sessionStorage).some((key) => + key.startsWith('oidc.user:') + ); + expect(hasOidcToken).to.equal(true); + }); }, - }, + cacheAcrossSpecs: true, + } ); + + // After session restore/setup, Cypress clears the page + // We must visit again and wait for APIs + setupApiIntercepts(); cy.visit(BASE_URL); + waitForAppReady(); + waitForCriticalApis(); + log.snapshot('after'); log.end(); }); + +/** + * Custom command to ensure the app is ready for testing. + * Use in beforeEach when you need to ensure APIs have loaded. + * Does not re-visit if already on the app. + */ +Cypress.Commands.add('waitForApp', () => { + // Check if we're already on the app + cy.url().then((url) => { + const isOnApp = url.includes(new URL(BASE_URL).host); + + if (!isOnApp) { + // Need to visit the app + setupApiIntercepts(); + cy.visit(BASE_URL); + waitForCriticalApis(); + } + + waitForAppReady(); + }); +}); diff --git a/cypress/src/e2e/support/index.ts b/cypress/src/e2e/support/index.ts index 1e16bacb4..c9c2ac24c 100644 --- a/cypress/src/e2e/support/index.ts +++ b/cypress/src/e2e/support/index.ts @@ -16,3 +16,26 @@ import './commands'; import '../../support/adminHelpers'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Chainable { + /** + * Custom command to log in a user via Cognito OAuth2/OIDC. + * Uses cy.session() for caching across specs. + * @param role - The role to log in as ('admin' or 'user') + * @example cy.loginAs('admin') + */ + loginAs(role?: 'admin' | 'user'): Chainable; + + /** + * Custom command to ensure the app is ready for testing. + * Waits for critical APIs to complete without re-visiting if already on app. + * @example cy.waitForApp() + */ + waitForApp(): Chainable; + } + } +} diff --git a/cypress/src/shared/specs/admin.shared.spec.ts b/cypress/src/shared/specs/admin.shared.spec.ts new file mode 100644 index 000000000..d5c041a05 --- /dev/null +++ b/cypress/src/shared/specs/admin.shared.spec.ts @@ -0,0 +1,119 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * Shared test suite for Admin Navigation features. + * Can be used by both smoke tests (with fixtures) and e2e tests (with real data). + */ + +import { + navigateAndVerifyAdminPage, + expandAdminMenu, + collapseAdminMenu, +} from '../../support/adminHelpers'; + +export function runAdminTests (options: { + expectMinItems?: boolean; + verifyFixtureData?: boolean; +} = {}) { + const { expectMinItems = false, verifyFixtureData = false } = options; + + it('Admin sees the Administration button and can expand/collapse menu', () => { + // Expand and verify menu items + expandAdminMenu(); + // Collapse and verify + collapseAdminMenu(); + }); + + it('Admin can access Configuration page', () => { + navigateAndVerifyAdminPage( + 'Configuration', + '/configuration', + 'Configuration', + 'custom' + ); + }); + + it('Model Management page loads and shows model cards', () => { + const minItems = expectMinItems ? 2 : 1; + navigateAndVerifyAdminPage('Model Management', '/model-management', 'Model', 'cards', minItems); + + if (verifyFixtureData) { + cy.contains('mistral-vllm').should('be.visible'); + cy.contains('claude-3-7').should('be.visible'); + cy.contains('InService').should('be.visible'); + } + }); + + it('API Token Management page loads and shows tokens table', () => { + const minItems = expectMinItems ? 3 : 0; + navigateAndVerifyAdminPage('API Token Management', '/api-token-management', 'API Token', 'table', minItems); + + // Wait for API tokens to load + cy.wait('@getApiTokens', { timeout: 30000 }); + + if (verifyFixtureData) { + cy.contains('Development Token').should('be.visible'); + cy.contains('Production API Key').should('be.visible'); + cy.contains('Test Environment Token').should('be.visible'); + } + }); + + it('RAG Management page loads and shows repositories table', () => { + const minItems = expectMinItems ? 3 : 0; + navigateAndVerifyAdminPage('RAG Management', '/repository-management', 'RAG', 'table', minItems); + cy.wait('@getRepositories', { timeout: 30000 }); + + if (verifyFixtureData) { + cy.contains('Technical Documentation').should('be.visible'); + cy.contains('Product Knowledge Base').should('be.visible'); + cy.contains('Training Materials').should('be.visible'); + } + }); + + it('MCP Management page loads and shows servers table', () => { + const minItems = expectMinItems ? 3 : 0; + navigateAndVerifyAdminPage('MCP Management', '/mcp-management', 'MCP', 'table', minItems); + cy.wait('@getMcp', { timeout: 30000 }); + + if (verifyFixtureData) { + cy.contains('Weather Service').should('be.visible'); + cy.contains('Database Connector').should('be.visible'); + cy.contains('File Processing Service').should('be.visible'); + } + }); + + it('MCP Workbench page loads', () => { + const minItems = expectMinItems ? 3 : 0; + const contentType = expectMinItems ? 'list' : 'custom'; + navigateAndVerifyAdminPage( + 'MCP Workbench', + '/mcp-workbench', + 'MCP Workbench', + contentType, + minItems + ); + cy.wait('@getMcpWorkbench', { timeout: 30000 }); + + if (verifyFixtureData) { + cy.get('li[data-testid="bad_actors_db.py"]').should('be.visible'); + cy.get('li[data-testid="calculator.py"]').should('be.visible'); + cy.get('li[data-testid="weather.py"]').should('be.visible'); + } + }); +} diff --git a/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts b/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts new file mode 100644 index 000000000..9c3c2d25b --- /dev/null +++ b/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts @@ -0,0 +1,222 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * Shared test suite for Bedrock model creation and chat workflow. + * Can be used by both smoke tests (with fixtures) and e2e tests (with real data). + */ + +import { navigateToAdminPage } from '../../support/adminHelpers'; +import { navigateAndVerifyChatPage } from '../../support/chatHelpers'; +import { + BedrockModelConfig, + openCreateModelWizard, + fillBedrockModelConfig, + completeBedrockModelWizard, + waitForModelCreationSuccess, + verifyModelInList, + deleteModelIfExists, + selectModelInChat, + deleteAllSessions, +} from '../../support/modelFormHelpers'; +import { + RepositoryConfig, + navigateToRepositoryManagement, + openCreateRepositoryWizard, + fillRepositoryConfig, + selectKnowledgeBase, + selectDataSource, + skipToCreateRepository, + completeRepositoryWizard, + waitForRepositoryCreationSuccess, + verifyRepositoryInList, + deleteRepositoryIfExists, +} from '../../support/repositoryHelpers'; +import { + PromptTemplateConfig, + navigateToPromptTemplates, + openCreatePromptTemplateWizard, + fillPromptTemplateConfig, + completePromptTemplateWizard, + waitForPromptTemplateCreationSuccess, + verifyPromptTemplateInList, + deletePromptTemplateIfExists, + selectPromptTemplateInChat, + selectDirectiveAndSend, +} from '../../support/promptTemplateHelpers'; + +// Amazon Nova Micro - cheapest Bedrock serverless model +const DEFAULT_TEST_MODEL: BedrockModelConfig = { + modelId: `e2e-nova-micro-${Date.now()}`, + modelName: 'bedrock/us.amazon.nova-micro-v1:0', + modelDescription: 'E2E test model - Amazon Nova Micro', + streaming: true, +}; + +export type BedrockWorkflowTestOptions = { + modelConfig?: BedrockModelConfig; + repositoryConfig?: RepositoryConfig; + promptTemplateConfig?: PromptTemplateConfig; + skipChat?: boolean; + skipCleanup?: boolean; +}; + +export function runBedrockModelWorkflowTests (options: BedrockWorkflowTestOptions = {}) { + const testModel = options.modelConfig || DEFAULT_TEST_MODEL; + const testRepository: RepositoryConfig = options.repositoryConfig || { + repositoryId: `e2e-repo-${Date.now()}`, + knowledgeBaseName: 'test-bedrock-kb', + dataSourceIndex: 0, + }; + const testPromptTemplatePersona: PromptTemplateConfig = { + title: `E2E Magic 8 Ball Persona ${Date.now()}`, + body: `You are a Magic 8 Ballβ€”a mystical oracle that responds to yes/no questions with cryptic, fate-laden answers. You speak only in the traditional Magic 8 Ball responses, selecting one at random for each query. Never explain yourself, provide reasoning, or deviate from these phrases. +Positive Responses: +It is certain +It is decidedly so +Without a doubt +Yes definitely +You may rely on it +As I see it, yes +Most likely +Outlook good +Yes +Signs point to yes + +Non-Committal Responses: +Reply hazy, try again +Ask again later +Better not tell you now +Cannot predict now +Concentrate and ask again + +Negative Responses: +Don't count on it +My reply is no +My sources say no +Outlook not so good +Very doubtful +Respond with only one phrase per message, chosen randomly. Treat every input as a question seeking guidance from the universe.`, + type: 'system', + sharePublic: true, + }; + const testPromptTemplateDirective: PromptTemplateConfig = { + title: `E2E Test Directive ${Date.now()}`, + body: 'Is it going to rain', + type: 'user', + sharePublic: true, + }; + + it('Admin creates a Bedrock model via wizard', () => { + navigateToAdminPage('Model Management'); + + openCreateModelWizard(); + fillBedrockModelConfig(testModel); + completeBedrockModelWizard(); + waitForModelCreationSuccess(testModel.modelId); + }); + + it('New model appears in Model Management list', () => { + navigateToAdminPage('Model Management'); + verifyModelInList(testModel.modelId); + }); + + it('Admin creates a repository with the new Bedrock model', () => { + navigateToRepositoryManagement(); + + openCreateRepositoryWizard(); + fillRepositoryConfig(testRepository); + selectKnowledgeBase(testRepository.knowledgeBaseName); + selectDataSource(testRepository.dataSourceIndex); + skipToCreateRepository(); + completeRepositoryWizard(); + waitForRepositoryCreationSuccess(testRepository.repositoryId); + }); + + it('New repository appears in RAG Management list', () => { + navigateToRepositoryManagement(); + verifyRepositoryInList(testRepository.repositoryId); + }); + + it('Admin creates a persona prompt template', () => { + navigateToPromptTemplates(); + + openCreatePromptTemplateWizard(); + fillPromptTemplateConfig(testPromptTemplatePersona); + completePromptTemplateWizard(); + waitForPromptTemplateCreationSuccess(testPromptTemplatePersona.title); + }); + + it('Persona prompt template appears in Prompt Templates list', () => { + navigateToPromptTemplates(); + verifyPromptTemplateInList(testPromptTemplatePersona.title); + }); + + it('Admin creates a directive prompt template', () => { + navigateToPromptTemplates(); + + openCreatePromptTemplateWizard(); + fillPromptTemplateConfig(testPromptTemplateDirective); + completePromptTemplateWizard(); + waitForPromptTemplateCreationSuccess(testPromptTemplateDirective.title); + }); + + it('Directive prompt template appears in Prompt Templates list', () => { + navigateToPromptTemplates(); + verifyPromptTemplateInList(testPromptTemplateDirective.title); + }); + + it('User selects model, applies persona, inserts directive, and sends message', () => { + navigateAndVerifyChatPage(); + selectModelInChat(testModel.modelId); + + // Apply the Magic 8 Ball persona (system prompt) + selectPromptTemplateInChat(testPromptTemplatePersona.title, 'system'); + // Insert directive template and send message + selectDirectiveAndSend(testPromptTemplateDirective.title); + }); + + it('Cleanup: delete all chat sessions', () => { + navigateAndVerifyChatPage(); + deleteAllSessions(); + }); + + it('Cleanup: delete test repository', () => { + navigateToRepositoryManagement(); + cy.wait(2000); + deleteRepositoryIfExists(testRepository.repositoryId); + }); + + it('Cleanup: delete persona prompt template', () => { + navigateToPromptTemplates(); + cy.wait(2000); + deletePromptTemplateIfExists(testPromptTemplatePersona.title); + }); + + it('Cleanup: delete directive prompt template', () => { + navigateToPromptTemplates(); + cy.wait(2000); + deletePromptTemplateIfExists(testPromptTemplateDirective.title); + }); + + it('Cleanup: delete test model', () => { + navigateToAdminPage('Model Management'); + cy.wait(2000); + deleteModelIfExists(testModel.modelId); + }); +} diff --git a/cypress/src/shared/specs/chat.shared.spec.ts b/cypress/src/shared/specs/chat.shared.spec.ts new file mode 100644 index 000000000..6f86be58a --- /dev/null +++ b/cypress/src/shared/specs/chat.shared.spec.ts @@ -0,0 +1,99 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * Shared test suite for Chat Page features. + * Can be used by both smoke tests (with fixtures) and e2e tests (with real data). + * + * Interceptors should be set up in beforeEach by the calling spec. + */ + +import { + navigateAndVerifyChatPage, + getModelInput, + getRagRepoInput, + getMessageInput, + getDropdownOptions, + selectModel, +} from '../../support/chatHelpers'; + +export function runChatTests (options: { + verifyFixtureData?: boolean; +} = {}) { + const { verifyFixtureData = false } = options; + + it('Model dropdown is populated and selectable', () => { + navigateAndVerifyChatPage(); + + // Wait for models to load + cy.wait('@getModels', { timeout: 30000 }); + + getModelInput() + .should('be.visible') + .and('not.be.disabled') + .click({ force: true }); + + // Wait for dropdown options to appear + getDropdownOptions() + .should('be.visible') + .and('have.length.at.least', 1); + + if (verifyFixtureData) { + cy.contains('mistral-vllm').should('be.visible'); + cy.contains('claude-3-7').should('be.visible'); + } + }); + + it('RAG repository dropdown is accessible', () => { + navigateAndVerifyChatPage(); + + // Wait for repositories to load + cy.wait('@getRepositories', { timeout: 30000 }); + + getRagRepoInput() + .should('be.visible') + .and('not.be.disabled') + .click({ force: true }); + + if (verifyFixtureData) { + getDropdownOptions() + .should('be.visible'); + cy.contains('Technical Documentation').should('be.visible'); + } + }); + + it('Chat interface has message input that requires model selection', () => { + navigateAndVerifyChatPage(); + + // Wait for models to load + cy.wait('@getModels', { timeout: 30000 }); + + // Initially, message input should be disabled until model is selected + getMessageInput() + .should('be.visible') + .and('be.disabled'); + + // Select a model + selectModel(); + + // Now message input should be enabled + getMessageInput() + .should('be.visible') + .and('not.be.disabled'); + }); +} diff --git a/cypress/src/shared/specs/user.shared.spec.ts b/cypress/src/shared/specs/user.shared.spec.ts new file mode 100644 index 000000000..109bef61f --- /dev/null +++ b/cypress/src/shared/specs/user.shared.spec.ts @@ -0,0 +1,58 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * Shared test suite for User role features. + * Can be used by both smoke tests (with fixtures) and e2e tests (with real data). + * + * Interceptors should be set up in beforeEach by the calling spec. + */ + +import { checkNoAdminButton } from '../../support/adminHelpers'; + +export function runUserTests () { + it('Non-admin does not see the Administration button', () => { + // Wait for configuration to load before checking UI + cy.wait('@getConfiguration', { timeout: 30000 }); + + checkNoAdminButton(); + }); + + it('Non-admin user cannot directly access admin pages', () => { + const adminPaths = [ + '#/configuration', + '#/model-management', + '#/repository-management', + '#/api-token-management', + '#/mcp-management', + '#/mcp-workbench' + ]; + + adminPaths.forEach((path) => { + cy.visit(path, { failOnStatusCode: false, timeout: 10000 }); + + cy.url({ timeout: 10000 }).should('satisfy', (url: string) => { + // Should be redirected away from admin path, or show access denied + // Accept homepage redirect as valid (which is what AdminRoute does) + return !url.includes(path.replace('#/', '')) || + url.includes('access-denied') || + url.includes('unauthorized'); + }); + }); + }); +} diff --git a/cypress/src/smoke/fixtures/api-tokens.json b/cypress/src/smoke/fixtures/api-tokens.json new file mode 100644 index 000000000..1c97ea689 --- /dev/null +++ b/cypress/src/smoke/fixtures/api-tokens.json @@ -0,0 +1,34 @@ +{ + "tokens": [ + { + "tokenUUID": "token-123e4567-e89b-12d3-a456-426614174000", + "name": "Development Token", + "username": "admin", + "groups": ["admin", "developers"], + "createdAt": "2024-01-15T10:30:00Z", + "expiresAt": "2024-04-15T10:30:00Z", + "lastUsed": "2024-01-20T14:22:00Z", + "isActive": true + }, + { + "tokenUUID": "token-987f6543-e21c-34d5-b678-539725285111", + "name": "Production API Key", + "username": "admin", + "groups": ["admin"], + "createdAt": "2024-01-10T08:15:00Z", + "expiresAt": "2024-07-10T08:15:00Z", + "lastUsed": "2024-01-22T09:45:00Z", + "isActive": true + }, + { + "tokenUUID": "token-456a7890-b12c-45d6-e789-012345678901", + "name": "Test Environment Token", + "username": "testuser", + "groups": [], + "createdAt": "2024-01-05T16:20:00Z", + "expiresAt": "2024-03-05T16:20:00Z", + "lastUsed": null, + "isActive": false + } + ] +} diff --git a/cypress/src/smoke/fixtures/collections.json b/cypress/src/smoke/fixtures/collections.json new file mode 100644 index 000000000..e352fdf58 --- /dev/null +++ b/cypress/src/smoke/fixtures/collections.json @@ -0,0 +1,114 @@ +{ + "collections": [ + { + "collectionId": "550e8400-e29b-41d4-a716-446655440001", + "repositoryId": "repo-001", + "name": "API Documentation Collection", + "description": "Complete API documentation and reference materials", + "chunkingStrategy": { + "type": "FIXED_SIZE", + "size": 512, + "overlap": 50 + }, + "allowChunkingOverride": true, + "metadata": { + "source": "documentation", + "category": "technical", + "version": "v1.0" + }, + "allowedGroups": ["admin", "developers"], + "embeddingModel": "text-embedding-ada-002", + "createdBy": "admin", + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-20T14:22:00Z", + "status": "ACTIVE", + "default": false + }, + { + "collectionId": "550e8400-e29b-41d4-a716-446655440002", + "repositoryId": "repo-001", + "name": "User Guides Collection", + "description": "End-user documentation and tutorials", + "chunkingStrategy": { + "type": "FIXED_SIZE", + "size": 1024, + "overlap": 100 + }, + "allowChunkingOverride": true, + "metadata": { + "source": "user-docs", + "category": "guides", + "audience": "end-users" + }, + "allowedGroups": ["admin", "support", "users"], + "embeddingModel": "text-embedding-ada-002", + "createdBy": "admin", + "createdAt": "2024-01-12T08:15:00Z", + "updatedAt": "2024-01-18T11:30:00Z", + "status": "ACTIVE", + "default": false + }, + { + "collectionId": "550e8400-e29b-41d4-a716-446655440003", + "repositoryId": "repo-002", + "name": "Product Specifications", + "description": "Detailed product specifications and feature documentation", + "chunkingStrategy": { + "type": "FIXED_SIZE", + "size": 768, + "overlap": 75 + }, + "allowChunkingOverride": false, + "metadata": { + "source": "product-specs", + "category": "specifications", + "confidentiality": "internal" + }, + "allowedGroups": ["admin", "product-team"], + "embeddingModel": "text-embedding-ada-002", + "createdBy": "admin", + "createdAt": "2024-01-10T16:45:00Z", + "updatedAt": "2024-01-22T09:15:00Z", + "status": "ACTIVE", + "default": false + }, + { + "collectionId": "550e8400-e29b-41d4-a716-446655440004", + "repositoryId": "repo-003", + "name": "Training Materials Archive", + "description": "Archived training materials and onboarding content", + "chunkingStrategy": { + "type": "FIXED_SIZE", + "size": 512, + "overlap": 50 + }, + "allowChunkingOverride": true, + "metadata": { + "source": "training", + "category": "archived", + "retention": "5-years" + }, + "allowedGroups": ["admin", "hr"], + "embeddingModel": "text-embedding-ada-002", + "createdBy": "testuser", + "createdAt": "2024-01-05T12:00:00Z", + "updatedAt": "2024-01-15T14:30:00Z", + "status": "ARCHIVED", + "default": false + }, + { + "collectionId": "550e8400-e29b-41d4-a716-446655440005", + "repositoryId": "repo-001", + "name": "Default Collection", + "description": "Default collection for general documents", + "allowChunkingOverride": true, + "allowedGroups": ["admin", "users"], + "embeddingModel": "text-embedding-ada-002", + "createdBy": "system", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "status": "ACTIVE", + "default": true + } + ] +} diff --git a/cypress/src/smoke/fixtures/configuration.json b/cypress/src/smoke/fixtures/configuration.json index ffe887893..b2980009e 100644 --- a/cypress/src/smoke/fixtures/configuration.json +++ b/cypress/src/smoke/fixtures/configuration.json @@ -21,7 +21,8 @@ "documentSummarization": true, "showRagLibrary": true, "showPromptTemplateLibrary": true, - "mcpConnections": true + "mcpConnections": true, + "showMcpWorkbench": true } }, "changedBy": "System", diff --git a/cypress/src/smoke/fixtures/env.json b/cypress/src/smoke/fixtures/env.json index 60a171023..f46c22504 100644 --- a/cypress/src/smoke/fixtures/env.json +++ b/cypress/src/smoke/fixtures/env.json @@ -2,6 +2,7 @@ "AUTHORITY": "http://localhost", "CLIENT_ID": "1234", "ADMIN_GROUP": "admin", + "USER_GROUP": "", "JWT_GROUPS_PROP": "cognito:groups", "CUSTOM_SCOPES": [], "RESTAPI_URI": "", diff --git a/cypress/src/smoke/fixtures/mcp-server.json b/cypress/src/smoke/fixtures/mcp-server.json new file mode 100644 index 000000000..76ae73429 --- /dev/null +++ b/cypress/src/smoke/fixtures/mcp-server.json @@ -0,0 +1,59 @@ +{ + "Items": [ + { + "id": "user-mcp-001", + "name": "Personal Weather API", + "url": "https://api.weather.example.com/mcp", + "description": "Personal weather service integration", + "created": "2024-01-15T10:30:00Z", + "owner": "admin", + "status": "active", + "groups": ["weather", "public"], + "isOwner": true, + "canUse": true, + "customHeaders": { + "X-API-Key": "weather-api-key" + }, + "clientConfig": { + "name": "weather-client", + "version": "1.0.0" + } + }, + { + "id": "user-mcp-002", + "name": "Document Processing", + "url": "https://docs.example.com/mcp", + "description": "Document analysis and processing service", + "created": "2024-01-10T08:15:00Z", + "owner": "admin", + "status": "active", + "groups": ["documents", "admin"], + "isOwner": true, + "canUse": true, + "customHeaders": {}, + "clientConfig": { + "name": "doc-processor", + "version": "2.1.0" + } + }, + { + "id": "user-mcp-003", + "name": "Analytics Service", + "url": "https://analytics.example.com/mcp", + "description": "Data analytics and reporting", + "created": "2024-01-12T12:45:00Z", + "owner": "testuser", + "status": "inactive", + "groups": ["analytics"], + "isOwner": false, + "canUse": true, + "customHeaders": { + "Authorization": "Bearer analytics-token" + }, + "clientConfig": { + "name": "analytics-client", + "version": "1.5.2" + } + } + ] +} diff --git a/cypress/src/smoke/fixtures/mcp-workbench.json b/cypress/src/smoke/fixtures/mcp-workbench.json new file mode 100644 index 000000000..a3eb05c0b --- /dev/null +++ b/cypress/src/smoke/fixtures/mcp-workbench.json @@ -0,0 +1,19 @@ +{ + "tools": [ + { + "id": "bad_actors_db.py", + "updated_at": "2025-12-03T18:21:52+00:00", + "size": 6948 + }, + { + "id": "calculator.py", + "updated_at": "2025-11-17T20:16:11+00:00", + "size": 5303 + }, + { + "id": "weather.py", + "updated_at": "2025-11-17T20:22:14+00:00", + "size": 2901 + } + ] +} diff --git a/cypress/src/smoke/fixtures/mcp.json b/cypress/src/smoke/fixtures/mcp.json new file mode 100644 index 000000000..1e4b26d87 --- /dev/null +++ b/cypress/src/smoke/fixtures/mcp.json @@ -0,0 +1,55 @@ +{ + "Items": [ + { + "serverId": "mcp-server-001", + "name": "Weather Service", + "description": "Provides weather information and forecasts", + "status": "running", + "enabled": true, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-20T14:22:00Z", + "owner": "admin", + "serverType": "hosted", + "config": { + "port": 8080, + "environment": "production" + }, + "healthStatus": "healthy", + "lastHealthCheck": "2024-01-22T12:00:00Z" + }, + { + "serverId": "mcp-server-002", + "name": "Database Connector", + "description": "Connects to various database systems", + "status": "stopped", + "enabled": false, + "createdAt": "2024-01-10T08:15:00Z", + "updatedAt": "2024-01-18T11:30:00Z", + "owner": "admin", + "serverType": "hosted", + "config": { + "port": 8081, + "environment": "development" + }, + "healthStatus": "unhealthy", + "lastHealthCheck": "2024-01-21T15:45:00Z" + }, + { + "serverId": "mcp-server-003", + "name": "File Processing Service", + "description": "Handles file uploads and processing", + "status": "running", + "enabled": true, + "createdAt": "2024-01-12T12:45:00Z", + "updatedAt": "2024-01-22T09:15:00Z", + "owner": "testuser", + "serverType": "hosted", + "config": { + "port": 8082, + "environment": "production" + }, + "healthStatus": "healthy", + "lastHealthCheck": "2024-01-22T12:30:00Z" + } + ] +} diff --git a/cypress/src/smoke/fixtures/openid-config.json b/cypress/src/smoke/fixtures/openid-config.json index 8872d9de5..93e3479d5 100644 --- a/cypress/src/smoke/fixtures/openid-config.json +++ b/cypress/src/smoke/fixtures/openid-config.json @@ -1,29 +1,14 @@ { - "authorization_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/oauth2/authorize", - "end_session_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/logout", - "id_token_signing_alg_values_supported": [ - "RS256" - ], - "issuer": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_L8pIxpQ9v", - "jwks_uri": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_L8pIxpQ9v/.well-known/jwks.json", - "response_types_supported": [ - "code", - "token" - ], - "revocation_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/oauth2/revoke", - "scopes_supported": [ - "openid", - "email", - "phone", - "profile" - ], - "subject_types_supported": [ - "public" - ], - "token_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/oauth2/token", - "token_endpoint_auth_methods_supported": [ - "client_secret_basic", - "client_secret_post" - ], - "userinfo_endpoint": "https://us-east-1l8pixpq9v.auth.us-east-1.amazoncognito.com/oauth2/userInfo" + "authorization_endpoint": "http://localhost/oauth2/authorize", + "end_session_endpoint": "http://localhost/logout", + "id_token_signing_alg_values_supported": ["RS256", "none"], + "issuer": "http://localhost", + "jwks_uri": "http://localhost/.well-known/jwks.json", + "response_types_supported": ["code", "token"], + "revocation_endpoint": "http://localhost/oauth2/revoke", + "scopes_supported": ["openid", "email", "phone", "profile"], + "subject_types_supported": ["public"], + "token_endpoint": "http://localhost/oauth2/token", + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "userinfo_endpoint": "http://localhost/oauth2/userInfo" } diff --git a/cypress/src/smoke/fixtures/repository.json b/cypress/src/smoke/fixtures/repository.json index fe51488c7..a5868956c 100644 --- a/cypress/src/smoke/fixtures/repository.json +++ b/cypress/src/smoke/fixtures/repository.json @@ -1 +1,111 @@ -[] +[ + { + "repositoryId": "repo-001", + "repositoryName": "Technical Documentation", + "type": "pgvector", + "embeddingModelId": "titan-embed", + "status": "UPDATE_COMPLETE", + "allowedGroups": ["admin"], + "metadata": { + "tags": [] + }, + "rdsConfig": { + "dbPort": 5432, + "username": "postgres", + "dbName": "postgres" + }, + "pipelines": [ + { + "trigger": "event", + "chunkingStrategy": { + "type": "fixed", + "size": 512, + "overlap": 51 + }, + "s3Prefix": "", + "autoRemove": true, + "collectionId": "default", + "s3Bucket": "docs" + } + ], + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-20T14:22:00Z", + "createdBy": "admin" + }, + { + "repositoryId": "repo-002", + "repositoryName": "Product Knowledge Base", + "type": "opensearch", + "embeddingModelId": "e5-embed", + "status": "UPDATE_COMPLETE", + "allowedGroups": ["admin"], + "metadata": { + "tags": ["open-rag"] + }, + "opensearchConfig": { + "dataNodeInstanceType": "r7g.large.search", + "volumeType": "gp3", + "dataNodes": 2, + "multiAzWithStandby": false, + "masterNodes": 0, + "volumeSize": 20, + "masterNodeInstanceType": "r7g.large.search" + }, + "pipelines": [ + { + "metadata": { + "customFields": {}, + "tags": ["open-pipe"] + }, + "autoRemove": true, + "trigger": "event", + "chunkingStrategy": { + "type": "fixed", + "size": 1000, + "overlap": 51 + }, + "s3Prefix": "", + "collectionId": "default", + "s3Bucket": "lisa-rag-pipeline" + } + ], + "createdAt": "2024-01-10T08:15:00Z", + "updatedAt": "2024-01-18T11:30:00Z", + "createdBy": "admin" + }, + { + "repositoryId": "repo-003", + "repositoryName": "Training Materials", + "type": "pgvector", + "embeddingModelId": "qwen3-embed-06b", + "status": "CREATE_COMPLETE", + "allowedGroups": [], + "metadata": { + "tags": ["repo-non-chunk"] + }, + "rdsConfig": { + "dbPort": 5432, + "username": "postgres", + "dbName": "postgres" + }, + "pipelines": [ + { + "metadata": { + "customFields": {}, + "tags": ["none-chunk-pipe"] + }, + "autoRemove": true, + "trigger": "event", + "chunkingStrategy": { + "type": "none" + }, + "s3Prefix": "", + "collectionId": "default", + "s3Bucket": "lisa-rag-pipeline" + } + ], + "createdAt": "2025-12-18T17:06:48.878529Z", + "updatedAt": "2025-12-18T17:06:48.878533Z", + "createdBy": "testuser" + } +] diff --git a/cypress/src/smoke/fixtures/session-detail.json b/cypress/src/smoke/fixtures/session-detail.json new file mode 100644 index 000000000..11925e63e --- /dev/null +++ b/cypress/src/smoke/fixtures/session-detail.json @@ -0,0 +1,100 @@ +{ + "sessionId": "f56fc284-629c-4ba7-ab3d-56f4a21c13ee", + "userId": "testuser", + "name": "Technical Discussion", + "startTime": "2026-01-02T08:30:00.000000+00:00", + "lastUpdated": "2026-01-02T09:15:00.000000+00:00", + "createTime": "2026-01-02T08:30:00.000000+00:00", + "isEncrypted": false, + "configuration": { + "sessionConfiguration": { + "ragTopK": 3, + "streaming": true, + "imageGenerationArgs": { + "size": "1024x1024", + "numberOfImages": 1, + "quality": "standard" + }, + "chatHistoryBufferSize": 7, + "max_tokens": null, + "markdownDisplay": true, + "showMetadata": false, + "modelArgs": { + "top_p": 0.01, + "frequency_penalty": null, + "seed": null, + "stop": [], + "presence_penalty": null, + "temperature": null, + "n": null + } + }, + "ragConfig": { + "repositoryId": "repo-001", + "collection": { + "collectionId": "550e8400-e29b-41d4-a716-446655440001", + "name": "API Documentation Collection" + }, + "embeddingModel": { + "modelId": "text-embedding-ada-002" + } + }, + "promptConfiguration": { + "promptTemplate": "You are a helpful assistant." + }, + "selectedModel": { + "modelId": "mistral-vllm", + "modelName": "mistralai/Mistral-7B-Instruct-v0.2", + "modelType": "textgen", + "status": "InService", + "streaming": true, + "features": [ + { + "name": "summarization", + "overview": "" + } + ], + "containerConfig": null, + "inferenceContainer": null, + "instanceType": null, + "autoScalingConfig": null, + "loadBalancerConfig": null, + "modelUrl": "", + "allowedGroups": null, + "guardrailsConfig": null, + "modelDescription": "" + } + }, + "history": [ + { + "type": "system", + "content": "You are a helpful assistant.", + "metadata": {}, + "toolCalls": [], + "guardrailTriggered": false + }, + { + "type": "human", + "content": "What is the difference between REST and GraphQL?", + "metadata": {}, + "toolCalls": [], + "guardrailTriggered": false + }, + { + "type": "ai", + "content": "REST and GraphQL are both API architectures, but they differ in several key ways. REST uses multiple endpoints for different resources, while GraphQL uses a single endpoint with flexible queries. GraphQL allows clients to request exactly the data they need, reducing over-fetching and under-fetching issues common in REST APIs.", + "metadata": { + "modelName": "mistral-vllm", + "modelKwargs": { + "max_tokens": null, + "modelKwargs": { + "top_p": 0.01, + "temperature": null + } + } + }, + "toolCalls": [], + "guardrailTriggered": false + } + ] +} diff --git a/cypress/src/smoke/fixtures/session.json b/cypress/src/smoke/fixtures/session.json index fe51488c7..97d001066 100644 --- a/cypress/src/smoke/fixtures/session.json +++ b/cypress/src/smoke/fixtures/session.json @@ -1 +1,29 @@ -[] +[ + { + "sessionId": "f56fc284-629c-4ba7-ab3d-56f4a21c13ee", + "name": "Technical Discussion", + "firstHumanMessage": "What is the difference between REST and GraphQL?", + "startTime": "2026-01-02T08:30:00.000000+00:00", + "createTime": "2026-01-02T08:30:00.000000+00:00", + "lastUpdated": "2026-01-02T09:15:00.000000+00:00", + "isEncrypted": false + }, + { + "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Product Questions", + "firstHumanMessage": "Tell me about the product features", + "startTime": "2026-01-01T14:20:00.000000+00:00", + "createTime": "2026-01-01T14:20:00.000000+00:00", + "lastUpdated": "2026-01-01T15:45:00.000000+00:00", + "isEncrypted": false + }, + { + "sessionId": "12345678-90ab-cdef-1234-567890abcdef", + "name": null, + "firstHumanMessage": "How do I get started with the platform?", + "startTime": "2025-12-28T10:00:00.000000+00:00", + "createTime": "2025-12-28T10:00:00.000000+00:00", + "lastUpdated": "2025-12-28T11:30:00.000000+00:00", + "isEncrypted": false + } +] diff --git a/cypress/src/e2e/specs/administration.e2e.spec.ts b/cypress/src/smoke/specs/admin.smoke.spec.ts similarity index 52% rename from cypress/src/e2e/specs/administration.e2e.spec.ts rename to cypress/src/smoke/specs/admin.smoke.spec.ts index e18553d25..22118a713 100644 --- a/cypress/src/e2e/specs/administration.e2e.spec.ts +++ b/cypress/src/smoke/specs/admin.smoke.spec.ts @@ -17,35 +17,23 @@ /// /** - * E2E suite for Administration features: - * - Ensures admin users can view and interact with the Administration menu - * - Verifies correct menu items and expansion behavior - * - Confirms non-admin users do not see the Administration option + * Smoke test suite for Admin Navigation features. + * Uses shared test suite with fixture data verification enabled. */ -import { - checkAdminButtonExists, - expandAdminMenu, - checkNoAdminButton, -} from '../../support/adminHelpers'; +import { runAdminTests } from '../../shared/specs/admin.shared.spec'; -describe('Administration features (E2E)', () => { +describe('Admin Navigation (Smoke)', () => { beforeEach(() => { - cy.clearAllSessionStorage(); - }); - - it('Admin sees the button', () => { cy.loginAs('admin'); - checkAdminButtonExists(); }); - it('Admin can expand menu', () => { - cy.loginAs('admin'); - expandAdminMenu(); + after(() => { + cy.clearAllSessionStorage(); }); - it('Non-admin does not see the button', () => { - cy.loginAs('user'); - checkNoAdminButton(); + runAdminTests({ + expectMinItems: true, + verifyFixtureData: true, }); }); diff --git a/cypress/src/smoke/specs/chat.smoke.spec.ts b/cypress/src/smoke/specs/chat.smoke.spec.ts new file mode 100644 index 000000000..21003c075 --- /dev/null +++ b/cypress/src/smoke/specs/chat.smoke.spec.ts @@ -0,0 +1,38 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * Smoke test suite for Chat Page features. + * Uses shared test suite with fixture-based session testing enabled. + */ + +import { runChatTests } from '../../shared/specs/chat.shared.spec'; + +describe('Chat Page (Smoke)', () => { + beforeEach(() => { + cy.loginAs('user'); + }); + + after(() => { + cy.clearAllSessionStorage(); + }); + + runChatTests({ + verifyFixtureData: true, + }); +}); diff --git a/cypress/src/smoke/specs/user.smoke.spec.ts b/cypress/src/smoke/specs/user.smoke.spec.ts new file mode 100644 index 000000000..969fc7611 --- /dev/null +++ b/cypress/src/smoke/specs/user.smoke.spec.ts @@ -0,0 +1,38 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/// + +/** + * Smoke test suite for User role features. + * Uses shared test suite. + */ + +import { runUserTests } from '../../shared/specs/user.shared.spec'; + +describe('User features (Smoke)', () => { + beforeEach(() => { + cy.loginAs('user'); + }); + + after(() => { + cy.clearAllSessionStorage(); + }); + + runUserTests({ + verifyFixtureData: true, + }); +}); diff --git a/cypress/src/smoke/support/commands.ts b/cypress/src/smoke/support/commands.ts index b4a2fbeac..05fe3dc5e 100644 --- a/cypress/src/smoke/support/commands.ts +++ b/cypress/src/smoke/support/commands.ts @@ -18,98 +18,157 @@ import { randomUUID, randomString, toBase64Url } from './utils'; -// Base application URL from Cypress config -const BASE_URL = Cypress.config('baseUrl'); - -// List of endpoints to stub with fixtures +// API endpoints with their aliases (matching E2E pattern) const API_STUBS = [ - 'models', - 'prompt-templates', - 'repository', - 'configuration', - 'health', - 'session', + { endpoint: 'models', alias: 'getModels' }, + { endpoint: 'prompt-templates', alias: 'getPromptTemplates' }, + { endpoint: 'repository', alias: 'getRepositories' }, + { endpoint: 'configuration', alias: 'getConfiguration' }, + { endpoint: 'health', alias: 'getHealth' }, + { endpoint: 'session', alias: 'getSessions' }, + { endpoint: 'api-tokens', alias: 'getApiTokens' }, + { endpoint: 'mcp', alias: 'getMcp' }, + { endpoint: 'mcp-server', alias: 'getMcpServers' }, + { endpoint: 'mcp-workbench', alias: 'getMcpWorkbench' }, + { endpoint: 'collections', alias: 'getCollections' }, ]; /** - * Custom command to log in a user via stubbed OAuth2/OIDC. - * Can log in as an 'admin' or a normal 'user'. - * - * @param {'admin'|'user'} role - The role to simulate (defaults to 'user'). + * Setup API stubs for smoke tests. */ -Cypress.Commands.add('loginAs', (role = 'user') => { - const isAdmin = role === 'admin'; - - let apiBase: string = '/dev/'; - // --- Stub env.js so window.env is correct --- - cy.fixture('env.json').then((env) => { - const script = `window.env = ${JSON.stringify(env)};`; - apiBase = env.API_BASE_URL.replace(/\/+$/, ''); - cy.intercept('GET', '**/env.js', { - body: script, - headers: { 'Content-Type': 'application/javascript' }, - }).as('stubEnv'); +function setupApiStubs (env: Record) { + const script = `window.env = ${JSON.stringify(env)};`; + const apiBase = String(env.API_BASE_URL).replace(/\/+$/, ''); + + cy.intercept('GET', '**/env.js', { + body: script, + headers: { 'Content-Type': 'application/javascript' }, + }).as('stubEnv'); + + // Stub all API endpoints with consistent aliases + API_STUBS.forEach(({ endpoint, alias }) => { + cy.intercept('GET', `**${apiBase}/${endpoint}*`, { fixture: `${endpoint}.json` }).as(alias); }); +} - // --- Stub all API endpoints --- - API_STUBS.forEach((name) => { - const alias = `stub${name.charAt(0).toUpperCase()}${name.slice(1)}`; - cy.intercept('GET', `**/${apiBase}/${name}*`, { fixture: `${name}.json` }).as(alias); - }); - - // --- Stub the OIDC /token endpoint with a fresh, valid-looking JWT --- - cy.fixture('oidc-user.json').then((user) => { - const now = Math.floor(Date.now() / 1000); - const profile = { - ...user.profile, - iat: now, - exp: now + 3600, - 'cognito:groups': isAdmin ? ['admin'] : ['user'], - sub: randomUUID(), - 'cognito:username': randomUUID(), - preferred_username: randomString(8), - origin_jti: randomUUID(), - event_id: randomUUID(), - aud: randomUUID(), - name: `User ${randomString(5)}`, - email: `${randomString(6)}@example.com`, - }; - - // --- Build an signed JWT that the OIDC client will accept --- - const header = { alg: 'none', typ: 'JWT' }; - const payload = { ...profile, token_use: 'id' }; - const id_token = `${toBase64Url(header)}.${toBase64Url(payload)}.`; - - // --- Build the stubbed response --- - const stubbed = { - ...user, - profile, - id_token, - access_token: randomString(30), - refresh_token: randomString(40), - expires_at: now + 3600, - }; - - cy.intercept('POST', '**/token', { - statusCode: 200, - headers: { 'Content-Type': 'application/json' }, - body: stubbed, - }).as('stubToken'); - }); +/** + * Build a mock OIDC user object. + */ +function buildOidcUser (role: 'admin' | 'user', env: Record) { + const isAdmin = role === 'admin'; + const groups = isAdmin ? ['admin'] : ['user']; + const now = Math.floor(Date.now() / 1000); + + const jwtPayload = { + sub: randomUUID(), + iss: env.AUTHORITY, + aud: env.CLIENT_ID, + exp: now + 3600, + iat: now, + token_use: 'id', + 'cognito:groups': groups, + 'cognito:username': randomUUID(), + preferred_username: `test-${role}`, + name: `Test ${role.charAt(0).toUpperCase() + role.slice(1)}`, + email: `test-${role}@example.com`, + origin_jti: randomUUID(), + event_id: randomUUID(), + }; + + const header = { alg: 'none', typ: 'JWT' }; + const id_token = `${toBase64Url(header)}.${toBase64Url(jwtPayload)}.`; + + return { + id_token, + access_token: randomString(30), + refresh_token: randomString(40), + token_type: 'Bearer', + expires_at: now + 3600, + profile: jwtPayload, + session_state: null, + scope: 'openid profile email', + }; +} - // --- Stub the OAuth2 authorize callback to redirect straight into the app --- - cy.intercept('GET', '**/authorize?*', (req) => { - const { state } = req.query; - req.redirect(`${BASE_URL}?code=1234&state=${state}`); - }).as('stubSigninCallback'); +/** + * Setup OIDC stubs for the login flow. + */ +function setupOidcStubs (role: 'admin' | 'user', env: Record) { + const oidcUser = buildOidcUser(role, env); - // --- Stub OIDC discovery document --- + // Stub OIDC discovery cy.intercept('GET', '**/.well-known/openid-configuration', { statusCode: 200, fixture: 'openid-config.json', }).as('stubOidc'); - // --- Trigger the login flow in the UI --- - cy.visit('/'); - cy.contains('Sign in').click(); + // Stub the token endpoint to return our mock user + cy.intercept('POST', '**/token', { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + id_token: oidcUser.id_token, + access_token: oidcUser.access_token, + refresh_token: oidcUser.refresh_token, + token_type: 'Bearer', + expires_in: 3600, + }, + }).as('stubToken'); + + // Stub the authorize endpoint to redirect back with a code + cy.intercept('GET', '**/authorize?*', (req) => { + const url = new URL(req.url); + const state = url.searchParams.get('state'); + const redirectUri = url.searchParams.get('redirect_uri') || 'http://localhost:3000'; + + // Redirect back to the app with auth code + req.redirect(`${redirectUri}?code=mock-auth-code&state=${state}`); + }).as('stubAuthorize'); +} + +/** + * Wait for the app to be fully loaded. + */ +function waitForAppReady () { + // Wait for "Loading configuration..." to disappear + cy.contains('Loading configuration...', { timeout: 15000 }).should('not.exist'); + + // Wait for spinners to disappear + cy.get('body').then(($body) => { + if ($body.find('[class*="awsui_spinner"]').length > 0) { + cy.get('[class*="awsui_spinner"]', { timeout: 10000 }).should('not.exist'); + } + }); +} + +/** + * Custom command to log in a user via stubbed OIDC flow. + */ +Cypress.Commands.add('loginAs', (role = 'user') => { + cy.fixture('env.json').then((env) => { + // Setup all stubs + setupApiStubs(env); + setupOidcStubs(role, env); + + // Visit the app + cy.visit('/'); + + // Click sign in to trigger OIDC flow + cy.contains('Sign in').click(); + + // Wait for the redirect and login to complete + cy.contains('Sign in', { timeout: 10000 }).should('not.exist'); + + // Wait for app to be ready + waitForAppReady(); + }); +}); + +/** + * Custom command to setup API stubs. + */ +Cypress.Commands.add('setupStubs', () => { + cy.fixture('env.json').then((env) => { + setupApiStubs(env); + }); }); diff --git a/cypress/src/smoke/support/index.ts b/cypress/src/smoke/support/index.ts index 1e16bacb4..c2cc568ce 100644 --- a/cypress/src/smoke/support/index.ts +++ b/cypress/src/smoke/support/index.ts @@ -16,3 +16,27 @@ import './commands'; import '../../support/adminHelpers'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Chainable { + /** + * Custom command to log in a user via stubbed OAuth2/OIDC. + * Should be wrapped in cy.session() for caching. + * @param role - The role to simulate ('admin' or 'user') + * @example cy.session('admin', () => cy.loginAs('admin')) + */ + loginAs(role?: 'admin' | 'user'): Chainable; + + /** + * Custom command to setup API stubs for a given role. + * Call this after cy.session() to re-establish intercepts. + * @param role - The role to simulate ('admin' or 'user') + * @example cy.setupStubs('admin') + */ + setupStubs(role?: 'admin' | 'user'): Chainable; + } + } +} diff --git a/cypress/src/smoke/support/utils.ts b/cypress/src/smoke/support/utils.ts index 829ee11e0..43dbabe1a 100644 --- a/cypress/src/smoke/support/utils.ts +++ b/cypress/src/smoke/support/utils.ts @@ -16,7 +16,7 @@ import { times, random } from 'lodash'; -export const toBase64Url = (obj: { alg: string; typ: string; }) => +export const toBase64Url = (obj: Record) => btoa(JSON.stringify(obj)) .replace(/=+$/, '') .replace(/\+/g, '-') diff --git a/cypress/src/support/adminHelpers.ts b/cypress/src/support/adminHelpers.ts index d5e0669cb..93f76c187 100644 --- a/cypress/src/support/adminHelpers.ts +++ b/cypress/src/support/adminHelpers.ts @@ -19,40 +19,157 @@ * Contains reusable checks for the Administration button & menu. */ -export function checkAdminButtonExists () { - cy.get('button[aria-label="Administration"]') - .should('exist') - .and('be.visible') - .and('have.attr', 'aria-expanded', 'false'); +import { + verifyCardsHaveData, + verifyCloudscapeTableHasData, + verifyListHasData, + waitForContentToLoad, +} from './dataHelpers'; + +// Cloudscape TopNavigation selectors +// Use aria-label to target the specific Administration menu, not just any [role="menu"] +const ADMIN_MENU_SELECTOR = '[role="menu"][aria-label="Administration"]'; +const MENU_ITEM_SELECTOR = '[role="menuitem"]'; + +// Core menu items that are always present for admin users +const EXPECTED_MENU_ITEMS = [ + 'Configuration', + 'Model Management', + 'RAG Management', + 'API Token Management', + 'MCP Management', + 'MCP Workbench', +]; + +/** + * Get the visible admin button with built-in retry. + * Cloudscape TopNavigation buttons have aria-label for accessibility. + */ +export function getAdminButton (): Cypress.Chainable { + // Use aria-label which is reliable in Cloudscape TopNavigation + return cy.get('header button[aria-label="Administration"]'); } +export function getLibraryButton (): Cypress.Chainable { + // Use aria-label which is reliable in Cloudscape TopNavigation + return cy.get('header button[aria-label="Libraries"]'); +} +/** + * Expand the admin menu and verify all items are present + */ export function expandAdminMenu () { - // click β†’ verify expanded β†’ verify menu items - cy.get('button[aria-label="Administration"]') - .filter(':visible') + // Wait for both Administration and Libraries buttons to be visible + // This prevents clicking Administration before the header is fully rendered + getLibraryButton().should('be.visible'); + getAdminButton().should('be.visible'); + + getAdminButton() .click() .should('have.attr', 'aria-expanded', 'true'); - cy.get('[role="menu"]') - .should('be.visible'); - - cy.get('[role="menuitem"]') - .should('have.length', 5) + // Wait for the Administration menu specifically (not Libraries or other menus) + cy.get(ADMIN_MENU_SELECTOR) + .should('be.visible') + .find(MENU_ITEM_SELECTOR) + .filter(':visible') + .should('have.length.at.least', EXPECTED_MENU_ITEMS.length) .then(($items) => { - const labels = $items - .map((_, el) => Cypress.$(el).text().trim()) - .get(); - expect(labels).to.deep.equal([ - 'Configuration', - 'Model Management', - 'RAG Management', - 'API Token Management', - 'MCP Management' - ]); + const labels = $items.map((_, el) => Cypress.$(el).text().trim()).get(); + // Verify core items are present + EXPECTED_MENU_ITEMS.forEach((item) => { + expect(labels).to.include(item); + }); }); } +/** + * Collapse the admin menu + */ +export function collapseAdminMenu () { + getAdminButton() + .click() + .should('have.attr', 'aria-expanded', 'false'); + + cy.get(ADMIN_MENU_SELECTOR).should('not.be.visible'); +} + export function checkNoAdminButton () { - cy.get('button[aria-label="Administration"]') - .should('not.exist'); + // Use the specific selector for the Administration button + cy.get('header button[aria-label="Administration"]').should('not.exist'); } + +/** + * Navigate to a specific admin page by menu item name + * @param menuItemName - The exact text of the menu item to click + */ +export function navigateToAdminPage (menuItemName: string) { + // First expand the menu using the same pattern as expandAdminMenu + expandAdminMenu(); + + // Then click the specific menu item + cy.contains(MENU_ITEM_SELECTOR, menuItemName) + .filter(':visible') + .click(); +} + +/** + * Verify that an admin page has loaded correctly + * @param urlFragment - The expected URL fragment (e.g., '/admin/configuration') + * @param pageTitle - Optional expected page title text + */ +export function verifyAdminPageLoaded (urlFragment: string, pageTitle?: string) { + cy.url().should('include', urlFragment); + + if (pageTitle) { + cy.get('h1, h2, [data-testid="page-title"]') + .should('be.visible') + .and('contain.text', pageTitle); + } else { + cy.get('h1, h2, [data-testid="page-title"], main, [role="main"]') + .should('be.visible'); + } +} + +/** + * Combined helper to navigate to admin page and verify it has rendered with data + * @param menuItemName - The menu item to click + * @param urlFragment - Expected URL fragment + * @param pageTitle - Expected page title + * @param contentType - Type of content to verify ('table', 'cards', 'list', or 'custom') + * @param minItems - Minimum number of items expected + */ +export function navigateAndVerifyAdminPage ( + menuItemName: string, + urlFragment: string, + pageTitle?: string, + contentType: 'table' | 'cards' | 'list' | 'custom' = 'table', + minItems: number = 1 +) { + navigateToAdminPage(menuItemName); + verifyAdminPageLoaded(urlFragment, pageTitle); + waitForContentToLoad(); + + switch (contentType) { + case 'table': + verifyCloudscapeTableHasData(minItems); + break; + case 'cards': + verifyCardsHaveData(minItems); + break; + case 'list': + verifyListHasData(minItems); + break; + case 'custom': + // For custom verification, just ensure page loaded + break; + } +} + +// Re-export data helpers for backward compatibility +export { + verifyTableHasData, + verifyCloudscapeTableHasData, + verifyCardsHaveData, + verifyListHasData, + waitForContentToLoad, +} from './dataHelpers'; diff --git a/cypress/src/support/chatHelpers.ts b/cypress/src/support/chatHelpers.ts new file mode 100644 index 000000000..32c2cd3ac --- /dev/null +++ b/cypress/src/support/chatHelpers.ts @@ -0,0 +1,190 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/** + * chatHelpers.ts + * Contains reusable helpers for chat page navigation and verification. + */ + +// Chat page selectors +export const CHAT_SELECTORS = { + MODEL_INPUT: 'input[placeholder*="model" i], input[aria-label*="model" i]', + RAG_REPO_INPUT: 'input#rag-repository-autosuggest, input[placeholder*="RAG Repository" i]', + COLLECTION_INPUT: 'input#collection-autosuggest, input[placeholder*="collection" i]', + MESSAGE_INPUT: 'textarea[placeholder*="message" i]', + DROPDOWN_OPTION: '[role="option"], [role="menuitem"]', +}; + +/** + * Navigate to the AI Assistant (chat) page by clicking the menu item + */ +export function navigateToChatPage () { + // For e2e tests, login should already direct to chat page + // For smoke tests, we may need to click the menu item + // Check if we're already on the chat page + cy.url().then((url) => { + if (!url.includes('/ai-assistant')) { + cy.get('a[aria-label="AI Assistant"]') + .eq(2) + .should('exist') + .and('be.visible') + .click(); + } + }); +} + +/** + * Verify that the chat page has loaded correctly + */ +export function verifyChatPageLoaded () { + cy.url().should('include', '/ai-assistant'); + + // Wait for the prompt input textarea to be visible + // Use attribute selectors that are stable across builds + cy.get('textarea[placeholder*="message" i]') + .first() + .should('exist') + .and('be.visible'); +} + +/** + * Wait for initial API calls to complete + * This prevents cancelled requests when interacting with dropdowns too early + */ +export function waitForInitialDataLoad () { + // Wait for any loading spinners to disappear + cy.get('[data-testid="loading"], .awsui-spinner, .loading', { timeout: 5000 }) + .should('not.exist'); + + // Give the page more time to stabilize after auth and initial API calls + cy.wait(3000); +} + +/** + * Navigate to chat page and verify it loaded + */ +export function navigateAndVerifyChatPage () { + navigateToChatPage(); + verifyChatPageLoaded(); + waitForInitialDataLoad(); +} + +/** + * Wait for chat sessions to load in the sidebar + */ +export function waitForSessionsToLoad () { + // Wait for loading state to complete + cy.get('[data-testid="loading"], .awsui-spinner, .loading') + .should('not.exist'); +} + +/** + * Select a session from the history sidebar by name + * @param sessionName - The name of the session to select + */ +export function selectSessionByName (sessionName: string) { + cy.contains(sessionName) + .should('be.visible') + .click(); +} + +/** + * Verify that a session has loaded with its history + * @param sessionId - The expected session ID in the URL + */ +export function verifySessionLoaded (sessionId: string) { + cy.url().should('include', `/ai-assistant/${sessionId}`); +} + +/** + * Verify that chat history messages are displayed + * @param messageTexts - Array of message text snippets to verify + */ +export function verifyChatHistory (messageTexts: string[]) { + messageTexts.forEach((text) => { + cy.contains(text).should('be.visible'); + }); +} + + +/** + * Get the model input element + */ +export function getModelInput (): Cypress.Chainable { + return cy.get(CHAT_SELECTORS.MODEL_INPUT).first(); +} + +/** + * Get the RAG repository input element + */ +export function getRagRepoInput (): Cypress.Chainable { + return cy.get(CHAT_SELECTORS.RAG_REPO_INPUT); +} + +/** + * Get the message input textarea + */ +export function getMessageInput (): Cypress.Chainable { + return cy.get(CHAT_SELECTORS.MESSAGE_INPUT); +} + +/** + * Get dropdown options + */ +export function getDropdownOptions (): Cypress.Chainable { + return cy.get(CHAT_SELECTORS.DROPDOWN_OPTION); +} + +/** + * Select a model from the dropdown + * @param modelName - Optional specific model name to select, otherwise selects first available + */ +export function selectModel (modelName?: string) { + getModelInput() + .should('be.visible') + .and('not.be.disabled') + .click({ force: true }); + + if (modelName) { + getDropdownOptions() + .contains(modelName) + .click(); + } else { + getDropdownOptions() + .should('be.visible') + .first() + .click(); + } +} + +/** + * Send a message that's already in the input field by clicking the send button + */ +export function sendMessageWithButton () { + cy.get('button[aria-label="Send message"]') + .should('be.visible') + .and('not.be.disabled') + .click(); +} + +/** + * Verify that a chat response was received + * @param minMessages - Minimum number of messages expected (default: 2 for user + assistant) + */ +export function verifyChatResponseReceived (minMessages: number = 2) { + cy.get('[data-testid="chat-message"]', { timeout: 30000 }) + .should('have.length.at.least', minMessages); +} diff --git a/cypress/src/support/dataHelpers.ts b/cypress/src/support/dataHelpers.ts new file mode 100644 index 000000000..333a0d0df --- /dev/null +++ b/cypress/src/support/dataHelpers.ts @@ -0,0 +1,83 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/** + * dataHelpers.ts + * Contains reusable helpers for verifying data rendering in tables, cards, and lists. + */ + +// Common loading indicator selectors +const LOADING_SELECTORS = '[data-testid="loading"], .awsui-spinner, [class*="awsui_spinner"]'; + +/** + * Verify that a table contains at least one data row (excluding headers) + * @param tableSelector - Optional CSS selector for the table (defaults to finding any table) + * @param minRows - Minimum number of data rows expected (defaults to 1) + */ +export function verifyTableHasData (tableSelector?: string, minRows: number = 1) { + const selector = tableSelector || 'table, [role="table"]'; + + cy.get(selector) + .should('be.visible') + .within(() => { + cy.get('tbody tr, [role="row"]:not([role="columnheader"])') + .should('have.length.at.least', minRows); + }); +} + +/** + * Verify that a Cloudscape table component has rendered with data + * Uses Cloudscape-specific selectors for better reliability + */ +export function verifyCloudscapeTableHasData (minRows: number = 1) { + // Cloudscape tables use specific CSS classes and structure + cy.get('tbody tr, [class*="awsui_row_"]') + .should('have.length.at.least', minRows); +} + +/** + * Verify that cards (used in model management) have rendered with data + * @param minCards - Minimum number of cards expected (defaults to 1) + */ +export function verifyCardsHaveData (minCards: number = 1) { + // Cloudscape cards use dynamic class names with hashes + cy.get('[class*="awsui_card_"][class*="awsui_card-selectable_"]') + .should('have.length.at.least', minCards); +} + +/** + * Verify that a list component has rendered with data + * Used for MCP Workbench and similar list-based views + * @param minItems - Minimum number of list items expected (defaults to 1) + */ +export function verifyListHasData (minItems: number = 1) { + // Look for list items with data-testid attributes (used in MCP Workbench) + cy.get('ul[class*="awsui_root_"] li[data-testid]') + .should('have.length.at.least', minItems); +} + +/** + * Wait for loading to complete and verify content is rendered + * Uses Cypress's built-in retry mechanism instead of arbitrary waits + */ +export function waitForContentToLoad () { + // Wait for loading indicators to disappear (Cypress will retry automatically) + cy.get('body').then(($body) => { + if ($body.find(LOADING_SELECTORS).length > 0) { + cy.get(LOADING_SELECTORS, { timeout: 10000 }).should('not.exist'); + } + }); +} diff --git a/cypress/src/support/modelFormHelpers.ts b/cypress/src/support/modelFormHelpers.ts new file mode 100644 index 000000000..3df8e952d --- /dev/null +++ b/cypress/src/support/modelFormHelpers.ts @@ -0,0 +1,186 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/** + * modelFormHelpers.ts + * Reusable helpers for model creation wizard interactions. + */ + +export type BedrockModelConfig = { + modelId: string; + modelName: string; + modelDescription?: string; + streaming?: boolean; +}; + +/** + * Open the Create Model wizard modal + */ +export function openCreateModelWizard () { + cy.contains('button', 'Create Model').should('be.visible').click(); + cy.contains('Base Model Configuration').should('be.visible'); +} + +/** + * Fill in the base model configuration for a third-party (Bedrock) model + */ +export function fillBedrockModelConfig (config: BedrockModelConfig) { + cy.get('input[placeholder="mistral-vllm"]').clear().type(config.modelId); + cy.get('input[placeholder*="mistralai/Mistral"]').clear().type(config.modelName); + + if (config.modelDescription) { + cy.get('input[placeholder*="Brief description"]').clear().type(config.modelDescription); + } + + if (config.streaming) { + cy.get('[data-testid="streaming-toggle"]') + .find('input[type="checkbox"]') + .then(($checkbox) => { + if (!$checkbox.is(':checked')) { + cy.wrap($checkbox).click({ force: true }); + } + }); + } +} + +/** + * Navigate through wizard steps for a third-party model and submit + */ +export function completeBedrockModelWizard () { + // Step 1 -> Guardrails (skip LISA-hosted steps) + cy.contains('button', 'Next').click(); + cy.contains('Guardrails Configuration', { timeout: 5000 }).should('be.visible'); + + // Guardrails -> Review + cy.contains('button', 'Next').click(); + cy.contains('Review and Create', { timeout: 5000 }).should('be.visible'); + + // Submit - target the primary button within the wizard container + cy.get('[data-testid="create-model-wizard"]') + .contains('button', 'Create Model') + .click(); +} + +/** + * Wait for model creation success notification + */ +export function waitForModelCreationSuccess (modelId: string) { + cy.contains(`Successfully created model: ${modelId}`, { timeout: 30000 }).should('be.visible'); +} + +/** + * Verify model appears in the model management list + */ +export function verifyModelInList (modelId: string) { + cy.contains(modelId, { timeout: 10000 }).should('be.visible'); +} + +/** + * Delete a model by ID (for cleanup) + */ +export function deleteModelIfExists (modelId: string) { + cy.get('body').then(($body) => { + if ($body.text().includes(modelId)) { + // Select the model card by clicking its radio button + cy.get(`[data-testid="model-card-${modelId}"]`) + .closest('[data-selection-item="item"]') + .find('input[type="radio"]') + .click({ force: true }); + + // Click the Actions dropdown + cy.get('[data-testid="model-actions-dropdown"]').click(); + + // Click Delete from the dropdown menu + cy.contains('[role="menuitem"]', 'Delete').click(); + + // Wait for confirmation modal and click Delete button + cy.get('[data-testid="confirmation-modal-delete-btn"]', { timeout: 5000 }) + .should('be.visible') + .click(); + + cy.wait(2000); + } + }); +} + +/** + * Select a model in the chat interface + */ +export function selectModelInChat (modelId: string) { + cy.get('input[placeholder*="model" i], input[aria-label*="model" i]', { timeout: 45000 }) + .first() + .should('not.be.disabled') + .click({ force: true }) + .type(modelId); + + cy.get('[role="option"], [role="menuitem"]') + .contains(modelId) + .should('be.visible') + .click(); +} + +/** + * Send a chat message and wait for response + * Sets up an intercept for the inference API before sending + */ +export function sendChatMessage (message: string) { + // Intercept the chat completions API call + cy.intercept('POST', '**/v2/serve/chat/completions').as('chatInference'); + + cy.get('textarea[placeholder*="message" i]') + .should('not.be.disabled') + .type(message); + + cy.get('button[aria-label="Send message"]').click(); +} + +/** + * Verify chat received a response by waiting for the inference API to complete + */ +export function verifyChatResponse (userMessage: string) { + // Wait for the inference API call to complete + cy.wait('@chatInference', { timeout: 60000 }).then((interception) => { + expect(interception.response.statusCode).to.be.oneOf([200, 201]); + }); + + // Verify the user message is displayed + cy.contains(userMessage).should('be.visible'); + + // Verify at least 2 messages exist (user + AI response) + cy.get('[class*="message"], [data-testid*="message"]', { timeout: 10000 }) + .should('have.length.at.least', 2); + + // Verify no error indicators + cy.get('[class*="status-indicator-error"]').should('not.exist'); +} + +/** + * Delete all chat sessions for the current user + */ +export function deleteAllSessions () { + // Click the Delete All Sessions button + cy.get('button[aria-label="Delete All Sessions"]') + .should('be.visible') + .click(); + + // Wait for confirmation modal and click Delete button + cy.get('[data-testid="confirmation-modal-delete-btn"]', { timeout: 5000 }) + .should('be.visible') + .click(); + + // Wait for deletion to complete + cy.wait(2000); +} diff --git a/cypress/src/support/promptTemplateHelpers.ts b/cypress/src/support/promptTemplateHelpers.ts new file mode 100644 index 000000000..7051e5c67 --- /dev/null +++ b/cypress/src/support/promptTemplateHelpers.ts @@ -0,0 +1,246 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/** + * promptTemplateHelpers.ts + * Contains reusable helpers for prompt template creation and management. + */ + +export type PromptTemplateConfig = { + title: string; + body: string; + type?: 'system' | 'user'; + sharePublic?: boolean; +}; + +/** + * Navigate to Prompt Templates Library page + */ +export function navigateToPromptTemplates () { + cy.get('header button[aria-label="Libraries"]') + .should('be.visible') + .click() + .should('have.attr', 'aria-expanded', 'true'); + + cy.get('[role="menu"][aria-label="Libraries"]') + .should('be.visible') + .find('[data-testid="prompt-template"]') + .should('be.visible') + .click(); + + cy.url().should('include', '/prompt-templates'); +} + +/** + * Open the Create Prompt Template wizard + */ +export function openCreatePromptTemplateWizard () { + cy.contains('button', 'Create Prompt Template') + .should('be.visible') + .and('not.be.disabled') + .click(); + + cy.url().should('include', '/prompt-templates/new'); +} + +/** + * Fill in the prompt template form + */ +export function fillPromptTemplateConfig (config: PromptTemplateConfig) { + // Wait for form to be ready + cy.get('[data-testid="prompt-template-title-input"]') + .should('exist'); + + // Fill in title using data-testid - find the actual input element + cy.get('[data-testid="prompt-template-title-input"]') + .find('input') + .should('be.visible') + .clear() + .type(config.title); + + // Select type if specified + if (config.type) { + cy.get('[data-testid="prompt-template-type-select"]') + .should('be.visible') + .click(); + + const typeLabel = config.type === 'system' ? 'Persona' : 'Directive'; + cy.get('[role="listbox"]') + .should('be.visible') + .contains('[role="option"]', typeLabel) + .click(); + } + + // Set share public toggle if specified + if (config.sharePublic !== undefined) { + cy.get('[data-testid="prompt-template-share-public-toggle"]') + .find('input[type="checkbox"]') + .then(($checkbox) => { + const isChecked = $checkbox.is(':checked'); + if (isChecked !== config.sharePublic) { + cy.wrap($checkbox).click({ force: true }); + } + }); + } + + // Fill in prompt body using data-testid - find the actual textarea element + cy.get('[data-testid="prompt-template-body-textarea"]') + .find('textarea') + .should('be.visible') + .clear() + .type(config.body, { delay: 0 }); +} + +/** + * Complete the prompt template creation + */ +export function completePromptTemplateWizard () { + cy.contains('button', 'Create Template') + .should('be.visible') + .and('not.be.disabled') + .click(); +} + +/** + * Wait for prompt template creation to succeed + */ +export function waitForPromptTemplateCreationSuccess (templateTitle: string) { + // Wait for redirect back to list + cy.url().should('match', /\/prompt-templates\/?$/); + + // Wait for success notification + cy.contains(`Successfully created Prompt Template: ${templateTitle}`, { timeout: 10000 }) + .should('be.visible'); +} + +/** + * Verify prompt template appears in the list + */ +export function verifyPromptTemplateInList (templateTitle: string) { + cy.contains('td', templateTitle, { timeout: 10000 }) + .should('be.visible'); +} + +/** + * Delete a prompt template if it exists + */ +export function deletePromptTemplateIfExists (templateTitle: string) { + cy.get('body').then(($body) => { + if ($body.text().includes(templateTitle)) { + // Select the template by clicking its radio button + cy.contains('tr', templateTitle) + .find('input[type="radio"]') + .click({ force: true }); + + // Click the Actions dropdown + cy.get('[data-testid="prompt-template-actions-dropdown"]') + .should('be.visible') + .and('not.be.disabled') + .click(); + + // Click Delete from the dropdown menu + cy.contains('[role="menuitem"]', 'Delete') + .should('be.visible') + .click(); + + // Wait for confirmation modal and click Delete button + cy.get('[data-testid="confirmation-modal-delete-btn"]', { timeout: 5000 }) + .should('be.visible') + .click(); + + // Wait for success notification + cy.contains(`Successfully deleted Prompt Template: ${templateTitle}`, { timeout: 10000 }) + .should('be.visible'); + } + }); +} + +/** + * Select a prompt template in chat + * @param templateTitle - The title of the template to select + * @param templateType - The type of template ('system' for Persona, 'user' for Directive) + */ +export function selectPromptTemplateInChat (templateTitle: string, templateType: 'system' | 'user' = 'user') { + if (templateType === 'system') { + // For Persona templates, use the "Edit Persona" button in Additional Configuration dropdown + cy.contains('button', 'Additional Configuration') + .should('be.visible') + .click(); + + cy.contains('[role="menuitem"]', 'Edit Persona') + .should('be.visible') + .click(); + } else { + // For Directive templates, use the "Insert Prompt Template" button + cy.get('button[aria-label="Insert Prompt Template"]') + .should('be.visible') + .click(); + } + + // Wait for modal to open + cy.get('[role="dialog"]') + .should('be.visible') + .within(() => { + // Search for and select the template + cy.get('input[placeholder="Search by title"]') + .should('be.visible') + .type(templateTitle); + + // Select from the dropdown + cy.contains('[role="option"]', templateTitle) + .should('be.visible') + .click(); + + // Click the Use button + const buttonText = templateType === 'system' ? 'Use Persona' : 'Use Prompt'; + cy.contains('button', buttonText) + .should('be.visible') + .and('not.be.disabled') + .click(); + }); + + // Wait for modal to close + cy.get('[role="dialog"]').should('not.exist'); +} + +/** + * Send a message that's already in the input field by clicking the send button + */ +export function sendMessageWithButton () { + cy.get('button[aria-label="Send message"]') + .should('be.visible') + .and('not.be.disabled') + .click(); +} + +/** + * Verify that a chat response was received + * @param minMessages - Minimum number of messages expected (default: 2 for user + assistant) + */ +export function verifyChatResponseReceived (minMessages: number = 2) { + cy.get('[data-testid="chat-message"]', { timeout: 30000 }) + .should('have.length.at.least', minMessages); +} + +/** + * Select a directive prompt template, which inserts text into the message input, then send it + * @param templateTitle - The title of the directive template to select + */ +export function selectDirectiveAndSend (templateTitle: string) { + selectPromptTemplateInChat(templateTitle, 'user'); + sendMessageWithButton(); + verifyChatResponseReceived(); +} diff --git a/cypress/src/support/repositoryHelpers.ts b/cypress/src/support/repositoryHelpers.ts new file mode 100644 index 000000000..233313ece --- /dev/null +++ b/cypress/src/support/repositoryHelpers.ts @@ -0,0 +1,184 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/** + * repositoryHelpers.ts + * Reusable helpers for repository creation and management interactions. + */ + +export type RepositoryConfig = { + repositoryId: string; + knowledgeBaseName: string; + dataSourceIndex?: number; +}; + +/** + * Navigate to the repository management page + */ +export function navigateToRepositoryManagement () { + cy.visit('/#/repository-management'); + cy.url().should('include', '/repository-management'); + cy.wait(1000); +} + +/** + * Open the Create Repository wizard modal + */ +export function openCreateRepositoryWizard () { + cy.contains('button', 'Create Repository').should('be.visible').click(); + cy.contains('Repository Configuration').should('be.visible'); +} + +/** + * Fill in the repository configuration with Bedrock Knowledge Base type + */ +export function fillRepositoryConfig (config: RepositoryConfig) { + // Set up intercept for knowledge bases API before selecting repository type + cy.intercept('GET', '**/bedrock-kb').as('getKnowledgeBases'); + + // Fill repository ID + cy.get('[data-testid="repository-id-input"]') + .should('be.visible') + .clear() + .type(config.repositoryId); + + // Select repository type: BEDROCK_KNOWLEDGE_BASE + cy.get('[data-testid="repository-type-select"]') + .find('button') + .click(); + + cy.get('[role="option"]') + .contains('BEDROCK_KNOWLEDGE_BASE') + .should('be.visible') + .click(); + + // Wait for knowledge bases to load after selecting repository type + cy.wait('@getKnowledgeBases', { timeout: 30000 }); +} + +/** + * Wait for knowledge bases to load and select a specific one + */ +export function selectKnowledgeBase (knowledgeBaseName: string) { + // Set up intercept for data sources API before selecting KB + cy.intercept('GET', '**/bedrock-kb/*/data-sources').as('getDataSources'); + + // Wait for the select to be visible (API already loaded in fillRepositoryConfig) + cy.get('[data-testid="knowledge-base-select"]').should('be.visible'); + + // Click the Knowledge Base dropdown button + cy.get('[data-testid="knowledge-base-select"]') + .find('button') + .click(); + + // Select the knowledge base by name + cy.get('[role="option"]') + .contains(knowledgeBaseName) + .should('be.visible') + .click(); + + // Wait for data sources to load after selecting KB + cy.wait('@getDataSources', { timeout: 30000 }); +} + +/** + * Wait for data sources to load and select one by index + */ +export function selectDataSource (index: number = 0) { + // Data sources API already loaded in selectKnowledgeBase + // Wait for the table to be visible + cy.get('[data-testid="data-sources-table"]').should('be.visible'); + + // Wait for table rows to be present + cy.get('[data-testid="data-sources-table"] tbody tr[data-selection-item="item"]') + .should('have.length.at.least', 1); + + // Select the data source checkbox by index + cy.get('[data-testid="data-sources-table"] tbody tr[data-selection-item="item"]') + .eq(index) + .find('input[type="checkbox"]') + .first() + .click({ force: true }); +} + +/** + * Skip to the create step in the repository wizard + */ +export function skipToCreateRepository () { + cy.contains('button', 'Skip to Create').should('be.visible').click(); +} + +/** + * Complete the repository creation wizard + */ +export function completeRepositoryWizard () { + // Scope the Create Repository button to the modal to avoid clicking the page button + cy.get('[data-testid="create-repository-modal"]') + .contains('button', 'Create Repository') + .should('be.visible') + .should('not.be.disabled') + .click(); +} + +/** + * Wait for repository creation success notification + */ +export function waitForRepositoryCreationSuccess (repositoryId: string) { + cy.contains(`Successfully created repository: ${repositoryId}`, { timeout: 30000 }) + .should('be.visible'); +} + +/** + * Verify repository appears in the repository management list + */ +export function verifyRepositoryInList (repositoryId: string) { + cy.contains(repositoryId, { timeout: 10000 }).should('be.visible'); +} + +/** + * Delete a repository by ID (for cleanup) + */ +export function deleteRepositoryIfExists (repositoryId: string) { + cy.get('body').then(($body) => { + if ($body.text().includes(repositoryId)) { + // Select the repository + cy.contains(repositoryId) + .closest('tr, [data-testid*="repository"]') + .find('input[type="radio"], input[type="checkbox"]') + .first() + .click({ force: true }); + + // Click the Actions dropdown or Delete button + cy.get('[data-testid="repository-actions-dropdown"], button') + .contains(/actions|delete/i) + .click(); + + // Click Delete from the dropdown menu if needed + cy.get('body').then(($body) => { + if ($body.find('[role="menuitem"]').length > 0) { + cy.contains('[role="menuitem"]', 'Delete').click(); + } + }); + + // Wait for confirmation modal and click Delete button + cy.get('[data-testid="confirmation-modal-delete-btn"]', { timeout: 5000 }) + .should('be.visible') + .click(); + + cy.wait(2000); + } + }); +} diff --git a/eslint.config.mjs b/eslint.config.mjs index e8e41fda4..6d5e3fffe 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,10 +22,44 @@ import reactHooks from 'eslint-plugin-react-hooks'; import importPlugin from 'eslint-plugin-import'; import globals from 'globals'; + +const tsRules = { + ...tseslint.configs.recommended.rules, + 'eqeqeq': ['error', 'smart'], + // Stylistic rules + '@stylistic/indent': 'error', + '@stylistic/quotes': ['error', 'single'], + '@stylistic/arrow-parens': 'error', + '@stylistic/arrow-spacing': 'error', + '@stylistic/brace-style': 'error', + '@stylistic/computed-property-spacing': ['error', 'never'], + '@stylistic/jsx-quotes': ['error', 'prefer-single'], + '@stylistic/keyword-spacing': ['error', { 'before': true }], + '@stylistic/semi': 'error', + '@stylistic/space-before-function-paren': 'error', + '@stylistic/space-infix-ops': 'error', + '@stylistic/space-unary-ops': 'error', + // TypeScript overrides + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-explicit-any': 'off' +} + +const reactRules = { + ...reactHooks.configs.recommended.rules, + // React hooks - downgrade new rules to warnings + 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/immutability': 'warn', + 'react-hooks/purity': 'warn', + 'react-hooks/use-memo': 'warn', +} + export default [ js.configs.recommended, + // React files - browser and jest globals only { - files: ['**/*.ts', '**/*.tsx'], + files: ['lib/user-interface/react/**/*.ts', 'lib/user-interface/react/**/*.tsx'], languageOptions: { parser: tsparser, parserOptions: { @@ -34,9 +68,34 @@ export default [ }, globals: { ...globals.browser, - ...globals.node, ...globals.jest, React: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + '@stylistic': stylistic, + 'react-hooks': reactHooks, + 'import': importPlugin, + }, + rules: { + ...tsRules, + ...reactRules + }, + }, + // All Cypress files - mocha, browser, jest, and cypress globals + { + files: ['cypress/**/*.ts', 'cypress/**/*.js'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 12, + sourceType: 'module', + }, + globals: { + ...globals.mocha, + ...globals.browser, + ...globals.jest, // For expect and other test utilities // Cypress globals cy: 'readonly', Cypress: 'readonly', @@ -45,37 +104,34 @@ export default [ plugins: { '@typescript-eslint': tseslint, '@stylistic': stylistic, - 'react-hooks': reactHooks, 'import': importPlugin, }, - rules: { - ...tseslint.configs.recommended.rules, - ...reactHooks.configs.recommended.rules, - 'eqeqeq': ['error', 'smart'], - // Stylistic rules - '@stylistic/indent': 'error', - '@stylistic/quotes': ['error', 'single'], - '@stylistic/arrow-parens': 'error', - '@stylistic/arrow-spacing': 'error', - '@stylistic/brace-style': 'error', - '@stylistic/computed-property-spacing': ['error', 'never'], - '@stylistic/jsx-quotes': ['error', 'prefer-single'], - '@stylistic/keyword-spacing': ['error', { 'before': true }], - '@stylistic/semi': 'error', - '@stylistic/space-before-function-paren': 'error', - '@stylistic/space-infix-ops': 'error', - '@stylistic/space-unary-ops': 'error', - // TypeScript overrides - '@typescript-eslint/ban-types': 'off', - '@typescript-eslint/consistent-type-definitions': ['error', 'type'], - '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/no-explicit-any': 'off', - // React hooks - downgrade new rules to warnings - 'react-hooks/set-state-in-effect': 'warn', - 'react-hooks/immutability': 'warn', - 'react-hooks/purity': 'warn', - 'react-hooks/use-memo': 'warn', + rules: tsRules, + }, + // All other TypeScript files - node and jest globals + { + files: ['**/*.ts', '**/*.tsx'], + ignores: [ + 'lib/user-interface/react/**/*', + 'cypress/**/*' + ], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 12, + sourceType: 'module', + }, + globals: { + ...globals.node, + ...globals.jest, + }, + }, + plugins: { + '@typescript-eslint': tseslint, + '@stylistic': stylistic, + 'import': importPlugin, }, + rules: tsRules, }, { files: ['**/*.js', '**/*.mjs'], @@ -91,6 +147,7 @@ export default [ '**/*.d.ts', '**/*.min.js', '**/{build,coverage,dist,venv,.venv}/**', + '**/*.config.ts', 'cypress/dist/**', 'ecs_model_deployer/dist/**', 'htmlcov/**', diff --git a/lambda/authorizer/lambda_functions.py b/lambda/authorizer/lambda_functions.py index db6d3a667..243048a99 100644 --- a/lambda/authorizer/lambda_functions.py +++ b/lambda/authorizer/lambda_functions.py @@ -66,7 +66,7 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i # Add management token to Admin groups groups = json.dumps([admin_group]) allow_policy = generate_policy(effect="Allow", resource=event["methodArn"], username=username) - allow_policy["context"] = {"username": username, "groups": groups} + allow_policy["context"] = {"username": username, "groups": groups, "authType": "management"} logger.debug(f"Generated policy: {allow_policy}") return allow_policy @@ -78,7 +78,7 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i groups = json.dumps(token_info.get("groups", [])) allow_policy = generate_policy(effect="Allow", resource=event["methodArn"], username=username) - allow_policy["context"] = {"username": username, "groups": groups} + allow_policy["context"] = {"username": username, "groups": groups, "authType": "api_token"} logger.debug(f"Generated policy: {allow_policy}") return allow_policy @@ -88,7 +88,7 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i groups = json.dumps(get_property_path(jwt_data, jwt_groups_property) or []) username = find_jwt_username(jwt_data) allow_policy = generate_policy(effect="Allow", resource=event["methodArn"], username=username) - allow_policy["context"] = {"username": username, "groups": groups} + allow_policy["context"] = {"username": username, "groups": groups, "authType": "jwt"} if not is_in_user_group: return deny_policy diff --git a/lambda/session/lambda_functions.py b/lambda/session/lambda_functions.py index b331072f1..9d25d5a46 100644 --- a/lambda/session/lambda_functions.py +++ b/lambda/session/lambda_functions.py @@ -570,9 +570,16 @@ def put_session(event: dict, context: dict) -> dict: ReturnValues="UPDATED_NEW", ) - # Publish event to SQS queue for metrics processing (use unencrypted data for metrics) + # Publish metrics to SQS queue for non-API-token users + # API token users have their metrics tracked in litellm_passthrough.py try: - if "USAGE_METRICS_QUEUE_NAME" in os.environ: + # Get auth type from authorizer context + request_context = event.get("requestContext", {}) + authorizer_context = request_context.get("authorizer", {}) + auth_type = authorizer_context.get("authType", "jwt") # Default to jwt for backwards compatibility + + # Only publish metrics for non-API-token users (JWT/UI users) + if auth_type != "api_token" and "USAGE_METRICS_QUEUE_NAME" in os.environ: # Create a copy of the event to send to SQS metrics_event = { "userId": user_id, @@ -585,7 +592,7 @@ def put_session(event: dict, context: dict) -> dict: QueueUrl=os.environ["USAGE_METRICS_QUEUE_NAME"], MessageBody=json.dumps(convert_decimal(metrics_event)), ) - logger.info(f"Published event to metrics queue for user {user_id}") + logger.info(f"Published metrics event to queue for user: {user_id}") else: logger.warning("USAGE_METRICS_QUEUE_NAME environment variable not set, metrics not published") except Exception as e: diff --git a/lib/docs/.vitepress/config.mts b/lib/docs/.vitepress/config.mts index 7d2d62c71..dd4852bf0 100644 --- a/lib/docs/.vitepress/config.mts +++ b/lib/docs/.vitepress/config.mts @@ -32,9 +32,9 @@ const navLinks = [ { text: 'Architecture Overview', link: '/admin/architecture', items: [ - { text: 'Serve', link: '/admin/architecture#serve' }, + { text: 'Serve', link: '/admin/architecture#lisa-serve' }, + { text: 'MCP', link: '/admin/architecture#lisa-mcp' }, { text: 'Chat UI', link: '/admin/architecture#chat-ui' }, - { text: 'Model Management', link: '/admin/architecture#model-management' }, ], }, { text: 'Deployment', link: '/admin/deploy', @@ -56,7 +56,7 @@ const navLinks = [ { text: 'Model Compatibility', link: '/config/model-compatibility' }, { text: 'Model Management API', link: '/config/model-management-api' }, { text: 'Model Management UI', link: '/config/model-management-ui' }, - { text: 'Guardrails', link: '/config/guardrails' }, + { text: 'Bedrock Guardrails', link: '/config/guardrails' }, { text: 'Usage & Features', link: '/config/usage' }, { text: 'RAG Repository', link: '/config/repositories' }, { text: 'Langfuse Tracing', link: '/config/langfuse-tracing'}, diff --git a/lib/docs/admin/deploy.md b/lib/docs/admin/deploy.md index b9e5fccfb..cb9e6463d 100644 --- a/lib/docs/admin/deploy.md +++ b/lib/docs/admin/deploy.md @@ -196,6 +196,30 @@ restApiConfig: sslCertIamArn: arn::iam:::server-certificate/ ``` +#### Accepting Self-Signed Certificate in Browser + +When using a self-signed certificate, the LISA UI will load normally, but API calls from the UI to the SERVE ALB (REST API ALB) will be blocked by the browser due to the self-signed certificate. To allow the UI to communicate with the SERVE ALB, you need to accept the certificate for the SERVE ALB domain: + +1. **Navigate to the SERVE ALB Domain**: Open a new browser tab and navigate directly to the REST API ALB domain (the serve domain). You can test this by navigating to `https:///health` or any other endpoint. +2. **Accept the Certificate**: When the browser displays a security warning about the self-signed certificate: + - Look for an "Advanced" or "Show Details" option + - Click "Proceed to [domain]" or "Accept the Risk and Continue" + - Some browsers may show this as a "rejected host" warning - you can accept it to proceed + +**Alternative Method - Using Developer Tools**: +1. **Open Developer Tools**: Press `F12` or right-click and select "Inspect" to open your browser's developer tools +2. **Navigate to the LISA UI** +3. **Go to the Network Tab**: This will show you the API requests that are being blocked when the LISA UI tries to connect to the SERVE ALB +4. **Click on a Failed Request**: Click on a failed request (typically showing a certificate error) to see the SERVE ALB domain +5. **Navigate to the Domain**: Copy the SERVE ALB domain from the failed request and navigate to it directly in a new tab to accept the certificate + +**Note**: The exact steps vary by browser: +- **Chrome/Edge**: Click "Advanced" β†’ "Proceed to [domain] (unsafe)" +- **Firefox**: Click "Advanced" β†’ "Accept the Risk and Continue" +- **Safari**: Click "Show Details" β†’ "visit this website" β†’ "Visit Website" + +After accepting the certificate for the SERVE ALB domain, the UI will be able to make API calls to the SERVE ALB successfully. The browser will remember your choice for this domain. + ### Step 9: Customize Model Deployment In the `ecsModels` section of `config-custom.yaml`, allow our deployment process to pull the model weights for you. diff --git a/lib/docs/admin/getting-started.md b/lib/docs/admin/getting-started.md index 041603fa9..6f4b0cde5 100644 --- a/lib/docs/admin/getting-started.md +++ b/lib/docs/admin/getting-started.md @@ -1,19 +1,18 @@ # What is LISA? The large language model (LLM) inference solution for Amazon Dedicated Cloud (ADC), [LISA](https://github.com/awslabs/LISA), -is an open-source, infrastructure-as-code solution. Customers deploy LISA directly into an Amazon Web Services (AWS) +is an open-source, infrastructure-as-code product. Customers deploy it directly into an Amazon Web Services (AWS) account. While LISA is specially designed for ADC regions that support government customers' most sensitive workloads, -it is also compatible with commercial regions. LISA compliments [Amazon Bedrock](https://aws.amazon.com/bedrock/) by -supporting built-in configuration with Amazon Bedrock's models and by offering additional capabilities out of the box: -a chat user interface (UI) with configurable features, authentication, centralized model orchestration, broad model -flexibility, and [model context protocol (MCP)](https://modelcontextprotocol.io/introduction) support. LISA is scalable -and ready to support production use cases. LISA's roadmap is customer driven, with capabilities launching monthly. -Reach-out to the LISA product team with questions or feature requests via your AWS Account team or GitHub Issues. +it is also compatible in any region. LISA is scalable and ready to support production use cases. + + +LISA accelerates GenAI adoption by offering built-in configurability with [Amazon Bedrock](https://aws.amazon.com/bedrock/) models, Knowledge Bases, and Guardrails. LISA also offers advanced capabilities like an optional enterprise-ready chat user interface (UI) with configurable features, authentication, resource access control, centralized model orchestration via LiteLLM, model self-hosting via Amazon ECS, retrieval augmented generation (RAG), APIs, and broad model context protocol (MCP) support and features. LISA is also compatible with OpenAI’s API specification making it easily configurable with supporting solutions. For example, the Continue plugin for VSCode and JetBrains integrated development environments (IDE). + +LISA's roadmap is customer-driven, with new capabilities launching monthly. Reach out to the product team to ask questions, provide feedback, and send feature requests via the "Contact Us" button above. # Major Features -LISA has four main components: serve, chat user interface, retrieval augmented generation (RAG), and APIs. -These capabilities support the following major features. +LISA has four modular components: serve, chat user interface, RAG, and MCP. Major features include: **Model Flexibility & Orchestration** @@ -45,12 +44,9 @@ generation capabilities. Lastly, the chat UI supports integration with an OIDC i **Model Context Protocol (MCP)** -LISA supports MCP, a popular open standard that enables developers to securely connect AI assistants to systems where -data lives. Customers can connect MCP servers with LISA and use the tools hosted on that server. For example, if an MCP -server is added to LISA that supports email/calendar actions then LISA customers can prompt for supported tasks. In this -case, customers could request help sending calendar invites on their behalf to specific colleagues based on everyone’s -availability. The LLM would automatically engage the appropriate MCP server tools and perform the necessary steps to -complete the task. +LISA MCP features offer customers flexibility with their agentic workloads. MCP is a popular open standard that enables developers to securely connect AI assistants to systems where data lives. First, customers can configure third party MCP servers with LISA via the MCP Connections feature. Next, Administrators can use LISA Workbench's Python editor to create, test, and deploy custom tools through LISA's hosted MCP server. This is great for rapid prototyping and deployment of custom functionality. + +Lastly, LISA MCP is a new stand-alone solution under the LISA umbrella. It can be deployed on its own, or alongside LISA Serve. LISA MCP extends LISA's existing MCP capabilities by offering self-hosting. Customers deploy, self-host, and manage MCP servers in enterprise-grade, scalable infrastructure. Administrators can quickly deploy STDIO, HTTP or SSE MCP servers through a single API call, or intuitive UI workflow. LISA MCP offers automatic scaling policies and secure VPC networking. This ensures traffic never leaves customers’ private network boundaries. LISA MCP supports dynamic container management by allowing customers to bring pre-built container images, or point to Amazon S3 artifacts that are automatically packaged into containers at deployment time. All hosted MCP servers benefit from security controls through API Gateway integration, including authentication, authorization, API keys, IDP lockdown, and JWT group enforcement. Administrative control on each server includes group-based access control, lifecycle automation through Step Functions, and built-in health monitoring at both container and load balancer levels. External integration with LISA MCP hosted servers allows access by agents, copilots, RPA bots, or SaaS workloads using the same secure endpoints. **Retrieval Augmented Generation (RAG)** @@ -79,12 +75,12 @@ centralized programmatic API and authenticate using temporary or long-lived API # Key Features & Benefits * Open source with no subscription or licensing fees. Costs are based on service usage. -* Customer driven roadmap with ongoing releases. LISA Is backed by a software development team. +* Customer driven roadmap with ongoing releases. LISA is backed by a software development team. * Maximum built-in model flexibility through self-hosting and LiteLLM compatibility, making LISA a two-way door decision. * Centralized and standardized model orchestration. LISA is LiteLLM compatible allowing easy configuration with 100+ models hosted by external providers, like Amazon Bedrock. LISA standardizes the unique API calls into the OpenAI format automatically. All that is required is an API key, model name, and API endpoint. -* Compliments Amazon Bedrock by supporting its models, and by offering added capabilities out of the box. This includes +* Complements Amazon Bedrock by supporting its models, and by offering added capabilities out of the box. This includes a production quality chat user interface with configurable features and authentication, a chat session API, built in model orchestration, added model flexibility, and model context protocol support. * Accelerates GenAI adoption with secure, scalable, production ready software. LISA’s modular components and APIs offer @@ -92,19 +88,11 @@ flexibility for different use cases. * Leverages AWS services that are FedRAMP High compliant. -*The below screenshot showcases LISA’s optional chat assistant user interface. On the left is the user’s Chat History. -In the center, the user can start a new chat session and prompt a model. Up top, the user can select from three -libraries: Document, Prompt, and MCP Connections. As an Administrator, this user also can access the Administration -menu. Here they configure application features and manage available models.* *See the next screenshot for more details.* +*The below screenshot showcases LISA’s optional chat assistant user interface. On the left is the user’s Chat History. In the center, the user can start a new chat session and prompt a model. Up top, the user can select from four libraries: Model, Document, Prompt, and MCP Connections. As an Administrator, this user also can access the Administration menu. Here they configure application features and manage available models. See the next screenshot for more details.* *See the next screenshot for more details.* ![LISA UI](../assets/LISA_UI.png) -*The below screenshot showcases LISA’s application configuration page. Here Administrators manage application features. -They can set up vector stores and automatic document ingestion pipelines to support RAG.* +*The below screenshot showcases LISA’s application feature configuration page. There are also separate configuration pages where Administrators manage RAG repos and collections, document ingestion pipelines, MCP servers, models, and API tokens.* ![LISA Config](../assets/LISA_Config.png) -*The below screenshot showcases LISA’s Model Management page. It is filtered to display the Amazon Nova models -configured with LISA, although they are hosted by the Amazon Bedrock service. Via LISA’s Model Management page, -Administrators configure self-hosted and externally hosted third party (3P) models with LISA. LISA is compatible with -over 100 externally hosted models via the LiteLLM proxy. Administrators do not need to worry about the 3P model -provider’s unique API requirements since LiteLLM handles the standardization.* +*The below screenshot showcases LISA’s Model Management page. It is filtered to display the Claude models configured with LISA, although they are hosted by the Amazon Bedrock service. Via LISA’s Model Management page, Administrators configure self-hosted and externally hosted third party (3P) models with LISA. LISA is compatible with over 100 externally hosted models via the LiteLLM proxy. Administrators do not need to worry about the 3P model provider’s unique API requirements since LiteLLM handles the standardization.* ![LISA Model Management](../assets/LISA_Model_Mgmt.png) diff --git a/lib/docs/assets/LISA_Config.png b/lib/docs/assets/LISA_Config.png index e871158a8..59680aaab 100644 Binary files a/lib/docs/assets/LISA_Config.png and b/lib/docs/assets/LISA_Config.png differ diff --git a/lib/docs/assets/LISA_Model_Mgmt.png b/lib/docs/assets/LISA_Model_Mgmt.png index e1b99b569..dae2c5bcd 100644 Binary files a/lib/docs/assets/LISA_Model_Mgmt.png and b/lib/docs/assets/LISA_Model_Mgmt.png differ diff --git a/lib/docs/assets/LISA_UI.png b/lib/docs/assets/LISA_UI.png index 5d711d46d..76add5af5 100644 Binary files a/lib/docs/assets/LISA_UI.png and b/lib/docs/assets/LISA_UI.png differ diff --git a/lib/metrics/metricsConstruct.ts b/lib/metrics/metricsConstruct.ts index 7c72585ba..d86b75499 100644 --- a/lib/metrics/metricsConstruct.ts +++ b/lib/metrics/metricsConstruct.ts @@ -93,6 +93,12 @@ export class MetricsConstruct extends Construct { stringValue: usageMetricsQueue.queueName, }); + // Store queue URL in SSM for cross-stack access + new StringParameter(this, 'UsageMetricsQueueUrl', { + parameterName: `${config.deploymentPrefix}/queue-url/usage-metrics`, + stringValue: usageMetricsQueue.queueUrl, + }); + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { restApiId: restApiId, rootResourceId: rootResourceId, @@ -107,9 +113,18 @@ export class MetricsConstruct extends Construct { dashboard.addWidgets( // Dashboard Title new cloudwatch.TextWidget({ - markdown: '# LISA Metrics Dashboard', + markdown: '# **LISA Metrics Dashboard**', + width: 24, + height: 1, + background: cloudwatch.TextWidgetBackground.TRANSPARENT + }), + + // Overview (Aggregate) metrics section + new cloudwatch.TextWidget({ + markdown: '## **Overview (Aggregate)**', width: 24, height: 1, + background: cloudwatch.TextWidgetBackground.TRANSPARENT }), // Total Prompts Widget new cloudwatch.GraphWidget({ @@ -122,7 +137,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), // Total RAG Usage Widget @@ -136,7 +151,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), // Total MCP Tool Calls Widget @@ -150,89 +165,105 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), - // Prompts by User Widget + // MCP Tool Calls by Tool Widget new cloudwatch.GraphWidget({ - title: 'Prompts by User', + title: 'MCP Tool Calls by Tool', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserPromptCount"\', \'Sum\', 3600)', + expression: 'SEARCH(\'{LISA/UsageMetrics,ToolName} MetricName="MCPToolCallsByTool"\', \'Sum\', 3600)', label: '', period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), - // RAG Usage by User Widget + // Unique Users Widget + new cloudwatch.SingleValueWidget({ + title: 'Total User Count', + metrics: [ + new cloudwatch.Metric({ + namespace: 'LISA/UsageMetrics', + metricName: 'UniqueUsers', + statistic: 'Maximum', + period: Duration.days(1), + }), + ], + width: 8, + height: 6, + }), + // Users by Group Widget new cloudwatch.GraphWidget({ - title: 'RAG Usage by User', + title: 'Groups by Membership Count', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserRAGUsageCount"\', \'Sum\', 3600)', + expression: 'SEARCH(\'{LISA/UsageMetrics,GroupName} MetricName="UsersPerGroup"\', \'Maximum\', 86400)', label: '', - period: Duration.hours(1), + period: Duration.days(1), }), ], - width: 12, + view: cloudwatch.GraphWidgetView.PIE, + width: 8, height: 6, }), - // MCP Tool Calls by User Widget + + // User Metrics section + new cloudwatch.TextWidget({ + markdown: '## **User Usage Metrics**', + width: 24, + height: 1, + background: cloudwatch.TextWidgetBackground.TRANSPARENT + }), + // Prompts by User Widget new cloudwatch.GraphWidget({ - title: 'MCP Tool Calls by User', + title: 'Prompts by User', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserMCPToolCalls"\', \'Sum\', 3600)', + expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserPromptCount"\', \'Sum\', 3600)', label: '', period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), - // MCP Tool Calls by Tool Widget + // RAG Usage by User Widget new cloudwatch.GraphWidget({ - title: 'MCP Tool Calls by Tool', + title: 'RAG Usage by User', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,ToolName} MetricName="MCPToolCallsByTool"\', \'Sum\', 3600)', + expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserRAGUsageCount"\', \'Sum\', 3600)', label: '', period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), - // Unique Users Widget - new cloudwatch.SingleValueWidget({ - title: 'Total User Count', - metrics: [ - new cloudwatch.Metric({ - namespace: 'LISA/UsageMetrics', - metricName: 'UniqueUsers', - statistic: 'Maximum', - period: Duration.days(1), - }), - ], - width: 12, - height: 6, - }), - // Users by Group Widget + // MCP Tool Calls by User Widget new cloudwatch.GraphWidget({ - title: 'Groups by Membership Count', + title: 'MCP Tool Calls by User', left: [ new cloudwatch.MathExpression({ - expression: 'SEARCH(\'{LISA/UsageMetrics,GroupName} MetricName="UsersPerGroup"\', \'Maximum\', 86400)', + expression: 'SEARCH(\'{LISA/UsageMetrics,UserId} MetricName="UserMCPToolCalls"\', \'Sum\', 3600)', label: '', - period: Duration.days(1), + period: Duration.hours(1), }), ], - view: cloudwatch.GraphWidgetView.PIE, - width: 12, + width: 8, height: 6, }), + + // Group Metrics section + new cloudwatch.TextWidget({ + markdown: '## **Group Usage Metrics**', + width: 24, + height: 1, + background: cloudwatch.TextWidgetBackground.TRANSPARENT + }), // Group Prompt Counts Widget new cloudwatch.GraphWidget({ title: 'Group Prompt Counts', @@ -243,7 +274,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), // Group RAG Usage Widget @@ -256,7 +287,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), // Group MCP Usage Widget @@ -269,7 +300,7 @@ export class MetricsConstruct extends Construct { period: Duration.hours(1), }), ], - width: 12, + width: 8, height: 6, }), ); diff --git a/lib/networking/vpc/index.ts b/lib/networking/vpc/index.ts index 97e40b52d..b10b870ab 100644 --- a/lib/networking/vpc/index.ts +++ b/lib/networking/vpc/index.ts @@ -151,9 +151,9 @@ export class Vpc extends Construct { ); if (!sgOverrides?.restAlbSecurityGroupId){ if (config.restApiConfig?.sslCertIamArn) { - SecurityGroupFactory.addHttpsTraffic(ecsModelAlbSg); + SecurityGroupFactory.addHttpsTraffic(restApiAlbSg); } else { - SecurityGroupFactory.addVpcTraffic(ecsModelAlbSg, vpc.vpcCidrBlock); + SecurityGroupFactory.addVpcTraffic(restApiAlbSg, vpc.vpcCidrBlock); } } diff --git a/lib/schema/ragSchema.ts b/lib/schema/ragSchema.ts index f5ce99478..4f5b21b2c 100644 --- a/lib/schema/ragSchema.ts +++ b/lib/schema/ragSchema.ts @@ -163,28 +163,29 @@ export const RagRepositoryMetadata = MetadataSchema.extend({ customFields: z.record(z.string(), z.any()).optional().describe('Custom metadata fields for the repository.'), }); -export const RagRepositoryConfigSchema = z - .object({ - repositoryId: z.string() - .nonempty() - .regex(/^[a-z0-9-]{3,20}/, 'Only lowercase alphanumeric characters and \'-\' are supported.') - .regex(/^(?!-).*(? { return !((input.type === RagRepositoryType.OPENSEARCH && input.opensearchConfig === undefined) || (input.type === RagRepositoryType.PGVECTOR && input.rdsConfig === undefined) || @@ -199,9 +200,16 @@ export type RDSConfig = RagRepositoryConfig['rdsConfig']; * Schema for RAG repository configuration used during deployment. * Omits database-managed fields like updatedAt that are set during DB operations. */ -export const RagRepositoryDeploymentConfigSchema = RagRepositoryConfigSchema.omit({ - updatedAt: true, - status: true, -}); +export const RagRepositoryDeploymentConfigSchema = BaseRagRepositoryConfigSchema + .omit({ + updatedAt: true, + status: true, + }) + .refine((input) => { + return !((input.type === RagRepositoryType.OPENSEARCH && input.opensearchConfig === undefined) || + (input.type === RagRepositoryType.PGVECTOR && input.rdsConfig === undefined) || + (input.type === RagRepositoryType.BEDROCK_KNOWLEDGE_BASE && input.bedrockKnowledgeBaseConfig === undefined)); + }) + .describe('Configuration schema for RAG repository used during deployment.'); export type RagRepositoryDeploymentConfig = z.infer; diff --git a/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py b/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py index 5643c2e32..16eddbfbb 100644 --- a/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py +++ b/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py @@ -35,6 +35,7 @@ get_model_guardrails, is_guardrail_violation, ) +from ....utils.metrics import publish_metrics_event # Local LiteLLM installation URL. By default, LiteLLM runs on port 4000. Change the port here if the # port was changed as part of the LiteLLM startup in entrypoint.sh @@ -293,6 +294,10 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: if guardrail_response: return guardrail_response + # Publish metrics for streaming chat completions (API users) + if api_path in ["chat/completions", "v1/chat/completions"] and response.status_code == 200: + publish_metrics_event(request, params, response.status_code) + # Normal streaming (no error or non-guardrail error) # Use guardrail-aware generator for chat/completions endpoints if api_path in ["chat/completions", "v1/chat/completions"]: @@ -314,4 +319,9 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: if response.status_code != 200: logger.error(f"LiteLLM error response: {response.text}") + + # Publish metrics for chat completions (API users) + if api_path in ["chat/completions", "v1/chat/completions"]: + publish_metrics_event(request, params, response.status_code) + return JSONResponse(response.json(), status_code=response.status_code) diff --git a/lib/serve/rest-api/src/auth.py b/lib/serve/rest-api/src/auth.py index f31d6f20c..6fee3708b 100644 --- a/lib/serve/rest-api/src/auth.py +++ b/lib/serve/rest-api/src/auth.py @@ -444,3 +444,40 @@ def _set_token_context(self, request: Request, token_info: Dict[str, Any]) -> No request.state.api_token_info = token_info request.state.username = token_info.get("username", "api-token") request.state.groups = token_info.get("groups", []) + + +def is_api_user(request: Request) -> bool: + """ + Check if the user authenticated with an API token. + + Args: + request: The FastAPI request object + + Returns: + True if user authenticated via API token, False otherwise + """ + return hasattr(request.state, "api_token_info") + + +def get_user_context(request: Request) -> tuple[str, list[str]]: + """ + Get user information from the request. + + Works with both API token and JWT authentication. + + Args: + request: The FastAPI request object + + Returns: + Tuple of (username, groups) + """ + if is_api_user(request): + token_info = request.state.api_token_info + username = token_info.get("username", "api-token") + groups = token_info.get("groups", []) + else: + # JWT-authenticated user or management token + username = getattr(request.state, "username", "unknown") + groups = getattr(request.state, "groups", []) + + return username, groups diff --git a/lib/serve/rest-api/src/utils/metrics.py b/lib/serve/rest-api/src/utils/metrics.py new file mode 100644 index 000000000..f6b43b967 --- /dev/null +++ b/lib/serve/rest-api/src/utils/metrics.py @@ -0,0 +1,138 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Metrics utilities for publishing usage data.""" + +import json +import logging +import os +import uuid +from datetime import datetime + +import boto3 +from fastapi import Request + +from ..auth import get_user_context + +logger = logging.getLogger(__name__) + +sqs_client = boto3.client("sqs", region_name=os.environ["AWS_REGION"]) + + +def extract_messages_for_metrics(params: dict) -> list[dict]: + """ + Extract messages from chat completion request parameters. + + Args: + params: The request parameters containing messages + + Returns: + List of message dictionaries suitable for metrics calculation + """ + messages = params.get("messages", []) + + # Convert to a format that matches what session lambda sends + formatted_messages = [] + for msg in messages: + role = msg.get("role", "user") + + # Map OpenAI roles to LISA message types + if role == "user": + msg_type = "human" + elif role == "assistant": + msg_type = "ai" + elif role == "system": + msg_type = "system" + else: + msg_type = role + + # Extract content - handle both string and array formats + content = msg.get("content", "") + content_text = "" + + if isinstance(content, str): + # Simple string content (from direct API calls) + content_text = content + elif isinstance(content, list): + # Array of content objects (from UI) + # Extract text from all text-type content items + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + content_text += item.get("text", "") + " " + content_text = content_text.strip() + + formatted_msg = { + "type": msg_type, + "content": content, # Keep original format for session compatibility + "metadata": {}, + } + + # Check if this message has RAG context in the extracted text + # RAG context is typically indicated by "File context:" in the message + if content_text and "file context:" in content_text.lower(): + # Mark this message as using RAG + formatted_msg["metadata"]["ragContext"] = True + + # Handle tool calls if present (for MCP metrics) + if "tool_calls" in msg: + formatted_msg["toolCalls"] = msg["tool_calls"] + + formatted_messages.append(formatted_msg) + + return formatted_messages + + +def publish_metrics_event(request: Request, params: dict, response_status: int) -> None: + """ + Publish metrics event to SQS queue for API users + + Args: + request: The FastAPI request object + params: The request parameters + response_status: HTTP response status code + """ + # Only publish metrics for successful completions + if response_status != 200: + return + + # Only publish if metrics queue is configured + queue_url = os.environ.get("USAGE_METRICS_QUEUE_URL") + if not queue_url: + logger.debug("Metrics queue URL not configured, skipping metrics") + return + + try: + username, groups = get_user_context(request) + messages = extract_messages_for_metrics(params) + + # Generate a synthetic session ID for API users + session_id = f"api-{int(datetime.now().timestamp())}-{uuid.uuid4().hex[:8]}" + + # Create metrics event in the same format as session lambda + metrics_event = { + "userId": username, + "sessionId": session_id, + "messages": messages, + "userGroups": groups, + "timestamp": datetime.now().isoformat(), + } + + # Publish to SQS + sqs_client.send_message(QueueUrl=queue_url, MessageBody=json.dumps(metrics_event)) + + logger.info(f"Published metrics event for API user: {username}") + + except Exception as e: + # Don't fail the request if metrics publishing fails + logger.error(f"Failed to publish metrics event: {e}") diff --git a/lib/serve/serveApplicationConstruct.ts b/lib/serve/serveApplicationConstruct.ts index 028e90157..72b6665b7 100644 --- a/lib/serve/serveApplicationConstruct.ts +++ b/lib/serve/serveApplicationConstruct.ts @@ -45,6 +45,7 @@ import { GuardrailsTable } from '../models/guardrails-table'; export type LisaServeApplicationProps = { vpc: Vpc; securityGroups: ISecurityGroup[]; + metricsQueueUrl?: string; } & BaseProps & StackProps; /** @@ -248,6 +249,12 @@ export class LisaServeApplicationConstruct extends Construct { container.addEnvironment('REGISTERED_MODELS_PS_NAME', this.modelsPs.parameterName); container.addEnvironment('LITELLM_DB_INFO_PS_NAME', litellmDbConnectionInfoPs.parameterName); container.addEnvironment('GUARDRAILS_TABLE_NAME', guardrailsTableName); + // Add metrics queue URL if provided + if (props.metricsQueueUrl) { + // Get the queue URL from SSM parameter + const queueUrl = StringParameter.valueForStringParameter(scope, props.metricsQueueUrl); + container.addEnvironment('USAGE_METRICS_QUEUE_URL', queueUrl); + } } restApi.node.addDependency(this.modelsPs); restApi.node.addDependency(litellmDbConnectionInfoPs); @@ -308,6 +315,26 @@ export class LisaServeApplicationConstruct extends Construct { litellmDbConnectionInfoPs.grantRead(restRole); restRole.attachInlinePolicy(invocation_permissions); restRole.attachInlinePolicy(guardrails_permissions); + + // Grant SQS send permissions if metrics queue URL is provided + if (props.metricsQueueUrl) { + // Get the queue name from SSM parameter + const queueName = StringParameter.valueForStringParameter( + scope, + `${config.deploymentPrefix}/queue-name/usage-metrics` + ); + const sqs_permissions = new Policy(scope, 'SQSMetricsPerms', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sqs:SendMessage'], + resources: [`arn:${config.partition}:sqs:${config.region}:${config.accountNumber}:${queueName}`], + }), + ] + }); + restRole.attachInlinePolicy(sqs_permissions); + } + if (serveRole) { this.modelsPs.grantRead(serveRole); litellmDbConnectionInfoPs.grantRead(serveRole); diff --git a/lib/stages.ts b/lib/stages.ts index 5982bc10d..83e75f5fc 100644 --- a/lib/stages.ts +++ b/lib/stages.ts @@ -317,6 +317,22 @@ export class LisaServeApplicationStage extends Stage { mcpApiStack.addDependency(apiBaseStack); this.stacks.push(mcpApiStack); } + let metricsStack: LisaMetricsStack | undefined; + if (config.deployMetrics) { + metricsStack = new LisaMetricsStack(this, 'LisaMetrics', { + ...baseStackProps, + authorizer: apiBaseStack.authorizer!, + stackName: createCdkId([config.deploymentName, config.appName, 'metrics', config.deploymentStage]), + description: `LISA-metrics: ${config.deploymentName}-${config.deploymentStage}`, + restApiId: apiBaseStack.restApiId, + rootResourceId: apiBaseStack.rootResourceId, + securityGroups: [networkingStack.vpc.securityGroups.lambdaSg], + vpc: networkingStack.vpc, + }); + metricsStack.addDependency(apiBaseStack); + metricsStack.addDependency(coreStack); + this.stacks.push(metricsStack); + } if (config.deployServe) { const serveStack = new LisaServeApplicationStack(this, 'LisaServe', { @@ -325,6 +341,7 @@ export class LisaServeApplicationStage extends Stage { stackName: createCdkId([config.deploymentName, config.appName, 'serve', config.deploymentStage]), vpc: networkingStack.vpc, securityGroups: [networkingStack.vpc.securityGroups.lambdaSg], + metricsQueueUrl: metricsStack ? `${config.deploymentPrefix}/queue-url/usage-metrics` : undefined, }); this.stacks.push(serveStack); serveStack.addDependency(networkingStack); @@ -385,25 +402,6 @@ export class LisaServeApplicationStage extends Stage { apiDeploymentStack.addDependency(ragStack); } - // Declare metricsStack here so that we can reference it in chatStack - let metricsStack: LisaMetricsStack | undefined; - if (config.deployMetrics) { - metricsStack = new LisaMetricsStack(this, 'LisaMetrics', { - ...baseStackProps, - authorizer: apiBaseStack.authorizer!, - stackName: createCdkId([config.deploymentName, config.appName, 'metrics', config.deploymentStage]), - description: `LISA-metrics: ${config.deploymentName}-${config.deploymentStage}`, - restApiId: apiBaseStack.restApiId, - rootResourceId: apiBaseStack.rootResourceId, - securityGroups: [networkingStack.vpc.securityGroups.lambdaSg], - vpc: networkingStack.vpc, - }); - metricsStack.addDependency(apiBaseStack); - metricsStack.addDependency(coreStack); - apiDeploymentStack.addDependency(metricsStack); - this.stacks.push(metricsStack); - } - if (config.deployChat) { const chatStack = new LisaChatApplicationStack(this, 'LisaChat', { ...baseStackProps, diff --git a/lib/user-interface/react/package.json b/lib/user-interface/react/package.json index c2de3e411..ffa3f25d6 100644 --- a/lib/user-interface/react/package.json +++ b/lib/user-interface/react/package.json @@ -1,7 +1,7 @@ { "name": "lisa-web", "private": true, - "version": "6.1.0", + "version": "6.1.1", "type": "module", "scripts": { "dev": "vite", @@ -55,7 +55,7 @@ "redux-persist": "^6.0.0", "regenerator-runtime": "^0.14.1", "rehype-katex": "^7.0.1", - "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwindcss": "^4.1.18", "tinyglobby": "^0.2.15", diff --git a/lib/user-interface/react/src/App.tsx b/lib/user-interface/react/src/App.tsx index b4efc48b3..72c95eadb 100644 --- a/lib/user-interface/react/src/App.tsx +++ b/lib/user-interface/react/src/App.tsx @@ -210,6 +210,18 @@ function App () { } /> + + + + ) : ( + + ) + } + /> {config?.configuration?.enabledComponents?.enableUserApiTokens && } />} - {config?.configuration?.enabledComponents?.showMcpWorkbench && - - } - /> - } {config?.configuration?.enabledComponents?.enableModelComparisonUtility && ({ + purgeStore: vi.fn(), + useAppSelector: vi.fn((selector) => { + const selectorStr = selector.toString(); + if (selectorStr.includes('selectCurrentUserIsAdmin')) return false; + if (selectorStr.includes('selectCurrentUserIsApiUser')) return false; + if (selectorStr.includes('selectCurrentUsername')) return 'Test User'; + return null; + }), +})); + +const mockAuth = { + isAuthenticated: true, + signoutRedirect: vi.fn(), + signinRedirect: vi.fn(), + removeUser: vi.fn(), + signoutSilent: vi.fn(), +}; + +const mockStore = configureStore({ + reducer: { + user: () => ({ + currentUser: { + isAdmin: false, + isApiUser: false, + name: 'Test User', + }, + }), + }, +}); + +const mockColorSchemeContext = { + colorScheme: Mode.Light, + setColorScheme: vi.fn(), +}; + +const renderTopbar = (props = {}) => { + return render( + + + + + + + + ); +}; + +describe('Topbar', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useAuth as any).mockReturnValue(mockAuth); + + // Mock window.env + (window as any).env = { + CLIENT_ID: 'test-client-id', + AUTHORITY: 'https://test-authority.com', + }; + }); + + it('calls signoutRedirect when sign out is clicked', async () => { + const user = userEvent.setup(); + const { purgeStore } = await import('@/config/store'); + + renderTopbar(); + + // Click the user profile dropdown button (the button with user icon) + const userButton = screen.getByRole('button', { expanded: false }); + await user.click(userButton); + + // Click the sign out option + await user.click(screen.getByText('Sign out')); + + // Verify that purgeStore and signoutRedirect were called + expect(purgeStore).toHaveBeenCalledOnce(); + expect(mockAuth.signoutRedirect).toHaveBeenCalledOnce(); + }); + + it('calls signinRedirect when sign in is clicked for unauthenticated user', async () => { + const user = userEvent.setup(); + + // Mock unauthenticated state + (useAuth as any).mockReturnValue({ + ...mockAuth, + isAuthenticated: false, + }); + + renderTopbar(); + + // Click the user profile dropdown button + const userButton = screen.getByRole('button', { expanded: false }); + await user.click(userButton); + + // Click the sign in option + await user.click(screen.getByText('Sign in')); + + // Verify that signinRedirect was called with correct redirect_uri + expect(mockAuth.signinRedirect).toHaveBeenCalledWith({ + redirect_uri: window.location.toString(), + }); + }); +}); diff --git a/lib/user-interface/react/src/components/Topbar.tsx b/lib/user-interface/react/src/components/Topbar.tsx index 4d69cc556..faecd3916 100644 --- a/lib/user-interface/react/src/components/Topbar.tsx +++ b/lib/user-interface/react/src/components/Topbar.tsx @@ -20,11 +20,12 @@ import { useHref, useNavigate } from 'react-router-dom'; import { applyDensity, Density, Mode } from '@cloudscape-design/global-styles'; import TopNavigation, { TopNavigationProps } from '@cloudscape-design/components/top-navigation'; import { getBaseURI } from './utils'; -import { purgeStore, useAppSelector } from '../config/store'; +import { purgeStore, useAppSelector } from '@/config/store'; import { selectCurrentUserIsAdmin, selectCurrentUserIsApiUser, selectCurrentUsername } from '../shared/reducers/user.reducer'; -import { IConfiguration } from '../shared/model/configuration.model'; +import { IConfiguration } from '@/shared/model/configuration.model'; import { ButtonDropdownProps } from '@cloudscape-design/components'; import ColorSchemeContext from '@/shared/color-scheme.provider'; +import { OidcConfig } from '@/config/oidc.config'; applyDensity(Density.Comfortable); @@ -190,7 +191,14 @@ function Topbar ({ configs }: TopbarProps): ReactElement { break; case 'signout': await purgeStore(); - await auth.signoutSilent(); + await auth.removeUser(); + await auth.signoutRedirect({ + extraQueryParams: { + client_id: OidcConfig.client_id, + redirect_uri: window.location.origin, + response_type: OidcConfig.response_type + } + }); break; case 'color-mode': setColorScheme(colorScheme === Mode.Light ? Mode.Dark : Mode.Light); diff --git a/lib/user-interface/react/src/components/adapters/lisa-chat-history.ts b/lib/user-interface/react/src/components/adapters/lisa-chat-history.ts index 28b1e0e5c..8a2fb41e7 100644 --- a/lib/user-interface/react/src/components/adapters/lisa-chat-history.ts +++ b/lib/user-interface/react/src/components/adapters/lisa-chat-history.ts @@ -44,6 +44,7 @@ export class LisaChatMessageHistory extends BaseChatMessageHistory { void message; // noop since messages are managed at the session level } + async addAIChatMessage (message: string): Promise { void message; // noop since messages are managed at the session level diff --git a/lib/user-interface/react/src/components/app-configured.tsx b/lib/user-interface/react/src/components/app-configured.tsx index f0f0e7b30..28e0d63e6 100644 --- a/lib/user-interface/react/src/components/app-configured.tsx +++ b/lib/user-interface/react/src/components/app-configured.tsx @@ -15,7 +15,7 @@ */ // es-lint-disable -import { AuthProvider, useAuth } from 'react-oidc-context'; +import { AuthProvider } from 'react-oidc-context'; import { HashRouter, Routes, Route } from 'react-router-dom'; import App from '../App'; import { onMcpAuthorization } from 'use-mcp'; @@ -45,10 +45,39 @@ function OAuthCallback () { ); } +const getGroups = (oidcUserProfile: UserProfile): any => { + if (window.env.JWT_GROUPS_PROP) { + const props: string[] = window.env.JWT_GROUPS_PROP.split('.'); + let currentNode: any = oidcUserProfile; + let found = true; + props.forEach((prop) => { + if (prop in currentNode) { + currentNode = currentNode[prop]; + } else { + found = false; + } + }); + return found ? currentNode : undefined; + } else { + return undefined; + } +}; + +const isAdmin = (userGroups: any): boolean => { + return window.env.ADMIN_GROUP ? userGroups.includes(window.env.ADMIN_GROUP) : false; +}; + +const isUser = (userGroups: any): boolean => { + return window.env.USER_GROUP ? userGroups.includes(window.env.USER_GROUP) : false; +}; + +const isApiUser = (userGroups: any): boolean => { + return window.env.API_GROUP ? userGroups.includes(window.env.API_GROUP) : false; +}; + function AppConfigured () { const dispatch = useAppDispatch(); const [oidcUser, setOidcUser] = useState(); - const auth = useAuth(); useEffect(() => { if (oidcUser) { @@ -67,36 +96,6 @@ function AppConfigured () { } }, [dispatch, oidcUser]); - const getGroups = (oidcUserProfile: UserProfile): any => { - if (window.env.JWT_GROUPS_PROP) { - const props: string[] = window.env.JWT_GROUPS_PROP.split('.'); - let currentNode: any = oidcUserProfile; - let found = true; - props.forEach((prop) => { - if (prop in currentNode) { - currentNode = currentNode[prop]; - } else { - found = false; - } - }); - return found ? currentNode : undefined; - } else { - return undefined; - } - }; - - const isAdmin = (userGroups: any): boolean => { - return window.env.ADMIN_GROUP ? userGroups.includes(window.env.ADMIN_GROUP) : false; - }; - - const isUser = (userGroups: any): boolean => { - return window.env.USER_GROUP ? userGroups.includes(window.env.USER_GROUP) : false; - }; - - const isApiUser = (userGroups: any): boolean => { - return window.env.API_GROUP ? userGroups.includes(window.env.API_GROUP) : false; - }; - const baseHref = document?.querySelector('base')?.getAttribute('href')?.replace(/\/$/, ''); // Check if we're on an OAuth callback URL (without hash) @@ -115,12 +114,16 @@ function AppConfigured () { { - if ((window.env.USER_GROUP && user && isUser(getGroups(user.profile))) || !window.env.USER_GROUP){ + if ((window.env.USER_GROUP && user && isUser(getGroups(user.profile))) || !window.env.USER_GROUP) { window.history.replaceState({}, document.title, `${window.location.pathname}${window.location.hash}`); setOidcUser(user); - } else { + } else { + // User not authorized - purge store and remove user from OIDC storage await purgeStore(); - await auth.signoutSilent(); + // Clear OIDC session storage to force re-authentication + const oidcStorageKey = `oidc.user:${window.env.AUTHORITY}:${window.env.CLIENT_ID}`; + sessionStorage.removeItem(oidcStorageKey); + window.location.href = window.location.origin; } }} > diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 08cc9058f..946e00aa2 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -19,6 +19,7 @@ import { useAuth } from 'react-oidc-context'; import Form from '@cloudscape-design/components/form'; import Box from '@cloudscape-design/components/box'; import SpaceBetween from '@cloudscape-design/components/space-between'; +import Spinner from '@cloudscape-design/components/spinner'; import { Autosuggest, ButtonGroup, Checkbox, @@ -85,6 +86,7 @@ export default function Chat ({ sessionId }) { const notificationService = useNotificationService(dispatch); const modelSelectRef = useRef(null); const bottomRef = useRef(null); + const scrollContainerRef = useRef(null); const auth = useAuth(); const userName = useAppSelector(selectCurrentUsername); @@ -126,6 +128,7 @@ export default function Chat ({ sessionId }) { const [hasUserInteractedWithModel, setHasUserInteractedWithModel] = useState(false); const [mermaidRenderComplete, setMermaidRenderComplete] = useState(0); const [dynamicMaxRows, setDynamicMaxRows] = useState(8); + const [shouldAutoScroll, setShouldAutoScroll] = useState(true); // Callback to handle Mermaid diagram rendering completion const handleMermaidRenderComplete = useCallback(() => { @@ -224,7 +227,7 @@ export default function Chat ({ sessionId }) { setModelFilterValue(selectedModel?.modelId ?? ''); }, [selectedModel]); - const { memory, setMemory, metadata } = useMemory( + const { memory, metadata } = useMemory( session, chatConfiguration, selectedModel, @@ -479,10 +482,42 @@ export default function Chat ({ sessionId }) { }, [sessionHealth]); useEffect(() => { - if (bottomRef.current) { - bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + if (shouldAutoScroll && bottomRef.current) { + // Use 'auto' instead of 'smooth' to prevent jagged scrolling during rapid streaming + // which was breaking AT_BOTTOM_THRESHOLD disabling auto-scroll without user input + bottomRef.current.scrollIntoView({ behavior: 'auto' }); } - }, [isStreaming, session, mermaidRenderComplete]); + }, [isStreaming, session, mermaidRenderComplete, shouldAutoScroll]); + + // Scroll event listener to detect scroll position + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + + const handleScroll = () => { + // Check if we're at the bottom + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + + // Small threshold to account for rounding issues + const AT_BOTTOM_THRESHOLD = 30; + + if (distanceFromBottom <= AT_BOTTOM_THRESHOLD) { + // At bottom - ensure auto-scroll is enabled + if (!shouldAutoScroll) { + setShouldAutoScroll(true); + } + } else { + // Not at bottom - disable auto-scroll + if (shouldAutoScroll) { + setShouldAutoScroll(false); + } + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => scrollContainer.removeEventListener('scroll', handleScroll); + }, [shouldAutoScroll]); // Reset tool call counter when session changes useEffect(() => { @@ -512,6 +547,9 @@ export default function Chat ({ sessionId }) { // Reset tool call counter when human provides input consecutiveToolCallCount.current = 0; + // Re-enable auto-scroll when user sends a new message + setShouldAutoScroll(true); + setSession((prev) => ({ ...prev, history: prev.history.concat(new LisaChatMessage({ @@ -642,9 +680,8 @@ export default function Chat ({ sessionId }) { setInternalSessionId={setInternalSessionId} setSession={setSession} handleSendGenerateRequest={handleSendGenerateRequest} - setMemory={setMemory} // eslint-disable-next-line react-hooks/exhaustive-deps - />), conditionalDeps([modals.documentSummarization], [modals.documentSummarization], [modals.documentSummarization, openModal, closeModal, fileContext, setFileContext, setUserPrompt, userPrompt, selectedModel, setSelectedModel, chatConfiguration, setChatConfiguration, auth.user?.profile.sub, setInternalSessionId, setSession, handleSendGenerateRequest, setMemory])) } + />), conditionalDeps([modals.documentSummarization], [modals.documentSummarization], [modals.documentSummarization, openModal, closeModal, fileContext, setFileContext, setUserPrompt, userPrompt, selectedModel, setSelectedModel, chatConfiguration, setChatConfiguration, auth.user?.profile.sub, setInternalSessionId, setSession, handleSendGenerateRequest])) } {useMemo(() => ( )} -
+
- {useMemo(() => session.history.map((message, idx) => ( + {loadingSession && ( + + + + Loading session... + Please wait while we load your conversation history + + + )} + + {useMemo(() => { + if (loadingSession) return null; + + return session.history.map((message, idx) => ()); // eslint-disable-next-line react-hooks/exhaustive-deps - )), [session.history, chatConfiguration])} + }, [session.history, chatConfiguration, loadingSession])} - {(isRunning || callingToolName) && !isStreaming && } - {session.history.length === 0 && sessionId === undefined && ( + {!loadingSession && session.history.length === 0 && sessionId === undefined && ( handleUserModelChange(value)} options={modelsOptions} ref={modelSelectRef} + controlId='model-selection-autosuggest' /> {window.env.RAG_ENABLED && !isImageGenerationMode && ( @@ -812,6 +863,7 @@ export default function Chat ({ sessionId }) { onChange={({ detail }) => setUserPrompt(detail.value)} onAction={handleAction} onKeyDown={handleKeyPress} + controlId='chat-prompt-input' secondaryActions={ void; userName: string; handleSendGenerateRequest: () => void; - setMemory: (state: ChatMemory) => void; }; export const DocumentSummarizationModal = ({ @@ -70,7 +67,6 @@ export const DocumentSummarizationModal = ({ setSession, userName, handleSendGenerateRequest, - setMemory }: DocumentSummarizationModalProps) => { const [selectedFiles, setSelectedFiles] = useState([]); const [successfulUploads, setSuccessfulUpload] = useState(undefined); @@ -167,13 +163,6 @@ export const DocumentSummarizationModal = ({ startTime: new Date(Date.now()).toISOString(), }; setSession(newSession); - - setMemory(new ChatMemory({ - chatHistory: new LisaChatMessageHistory(newSession), - returnMessages: false, - memoryKey: 'history', - k: chatConfiguration.sessionConfiguration.chatHistoryBufferSize, - })); } setSummarize(true); @@ -297,10 +286,15 @@ Repeat the following 2 steps 5 times. }} value={userPrompt} /> : null} - - - setCreateNewChatSession(detail.checked) - } /> + + setCreateNewChatSession(detail.checked)} + controlId='create-new-chat-session-toggle' + /> diff --git a/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx b/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx index 0cdc1abfb..8db8108d5 100644 --- a/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/MermaidDiagram.tsx @@ -20,6 +20,9 @@ import { ButtonGroup, StatusIndicator } from '@cloudscape-design/components'; import { downloadSvgAsPng } from '@/shared/util/downloader'; import { MERMAID_SANITIZATION_CONFIG } from '@/components/chatbot/config/mermaidSanitizationConfig'; import DOMPurify from 'dompurify'; +import { useContext } from 'react'; +import { Mode } from '@cloudscape-design/global-styles'; +import ColorSchemeContext from '@/shared/color-scheme.provider'; type MermaidDiagramProps = { chart: string; @@ -35,19 +38,24 @@ const MermaidDiagram: React.FC = React.memo(({ chart, id, i const [isLoading, setIsLoading] = useState(true); const mermaidInitialized = useRef(false); const lastRenderedChart = useRef(''); + const { colorScheme } = useContext(ColorSchemeContext); + const isDarkMode = colorScheme === Mode.Dark; - // Initialize Mermaid once + // Initialize Mermaid with theme based on mode useEffect(() => { - if (!mermaidInitialized.current) { - mermaid.initialize({ - startOnLoad: false, - theme: 'dark', - securityLevel: 'loose', - suppressErrorRendering: true, - }); - mermaidInitialized.current = true; + mermaid.initialize({ + startOnLoad: false, + theme: isDarkMode ? 'dark' : 'default', + securityLevel: 'loose', + suppressErrorRendering: true, + }); + mermaidInitialized.current = true; + // Force re-render when theme changes + if (lastRenderedChart.current) { + lastRenderedChart.current = ''; + setSvg(''); } - }, []); + }, [isDarkMode]); // Render the diagram once @@ -119,25 +127,26 @@ const MermaidDiagram: React.FC = React.memo(({ chart, id, i return (
Mermaid Error: {error}
- + Show diagram source
                         {chart}
                     
@@ -151,10 +160,10 @@ const MermaidDiagram: React.FC = React.memo(({ chart, id, i return (
Rendering Mermaid diagram... @@ -207,8 +216,8 @@ const MermaidDiagram: React.FC = React.memo(({ chart, id, i ref={containerRef} style={{ padding: '12px', - backgroundColor: '#1a1a1a', - border: '1px solid #333', + backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff', + border: isDarkMode ? '1px solid #333' : '1px solid #ddd', borderRadius: '4px', overflow: 'auto' }} diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.module.css b/lib/user-interface/react/src/components/chatbot/components/Message.module.css new file mode 100644 index 000000000..a8bd3d916 --- /dev/null +++ b/lib/user-interface/react/src/components/chatbot/components/Message.module.css @@ -0,0 +1,59 @@ +/* Scoped styles for Message component markdown content */ + +/* First child margin reset */ +.messageContent div :first-child { + margin-top: 0; +} + +/* List styling */ +.messageContent ol { + list-style-type: decimal; + padding-left: 1.5rem; + margin-bottom: 1rem; +} + +.messageContent ul { + list-style-type: disc; + padding-left: 1.5rem; + margin-bottom: 1rem; +} + +.messageContent li { + margin-bottom: 0.5rem; + line-height: 1.5; +} + +/* Paragraph styling */ +.messageContent p { + margin-bottom: 1rem; + line-height: 1.6; +} + +/* Table styling */ +.messageContent table { + border-collapse: collapse; + margin-bottom: 1rem; + width: 100%; +} + +/* Default/Light mode table styling */ +.messageContent th, +.messageContent td { + border: 1px solid #d1d5db; + padding: 0.5rem 0.75rem; + text-align: left; +} + +.messageContent tbody tr:nth-child(even) { + background-color: #f9fafb; +} + +/* Dark mode table styling */ +:global(.awsui-dark-mode) .messageContent th, +:global(.awsui-dark-mode) .messageContent td { + border: 1px solid #4b5563; +} + +:global(.awsui-dark-mode) .messageContent tbody tr:nth-child(even) { + background-color: #1f2937; +} diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.tsx b/lib/user-interface/react/src/components/chatbot/components/Message.tsx index f23084d84..ae24d6965 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Message.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Message.tsx @@ -18,20 +18,21 @@ import ReactMarkdown from 'react-markdown'; import Box from '@cloudscape-design/components/box'; import ExpandableSection from '@cloudscape-design/components/expandable-section'; import { ButtonDropdown, ButtonGroup, Grid, SpaceBetween, StatusIndicator } from '@cloudscape-design/components'; -import { JsonView, darkStyles } from 'react-json-view-lite'; +import { JsonView, darkStyles, defaultStyles } from 'react-json-view-lite'; import 'react-json-view-lite/dist/index.css'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { LisaChatMessage, LisaChatMessageMetadata, MessageTypes } from '../../types'; import { useAppSelector } from '@/config/store'; import { selectCurrentUsername } from '@/shared/reducers/user.reducer'; import ChatBubble from '@cloudscape-design/chat-components/chat-bubble'; import Avatar from '@cloudscape-design/chat-components/avatar'; -import remarkBreaks from 'remark-breaks'; import remarkMath from 'remark-math'; +import remarkGfm from 'remark-gfm'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; +import styles from './Message.module.css'; import { MessageContent } from '@langchain/core/messages'; import { base64ToBlob, fetchImage, getDisplayableMessage, messageContainsImage } from '@/components/utils'; @@ -43,6 +44,9 @@ import ImageViewer from '@/components/chatbot/components/ImageViewer'; import MermaidDiagram from '@/components/chatbot/components/MermaidDiagram'; import UsageInfo from '@/components/chatbot/components/UsageInfo'; import { merge } from 'lodash'; +import { useContext } from 'react'; +import { Mode } from '@cloudscape-design/global-styles'; +import ColorSchemeContext from '@/shared/color-scheme.provider'; type MessageProps = { message?: LisaChatMessage; @@ -66,6 +70,8 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami const [showImageViewer, setShowImageViewer] = useState(false); const [selectedImage, setSelectedImage] = useState(undefined); const [selectedMetadata, setSelectedMetadata] = useState(undefined); + const { colorScheme } = useContext(ColorSchemeContext); + const isDarkMode = colorScheme === Mode.Dark; useEffect(() => { if (resend) { @@ -115,7 +121,7 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami />
             );
         },
-    }), [isStreaming, onMermaidRenderComplete]); // Include isStreaming and onMermaidRenderComplete so the component can access them
+    }), [isStreaming, onMermaidRenderComplete, isDarkMode]);
 
-    const renderContent = (messageType: string, content: MessageContent, metadata?: LisaChatMessageMetadata) => {
+    const renderContent = (content: MessageContent, metadata?: LisaChatMessageMetadata) => {
         if (Array.isArray(content)) {
-            return content.map((item, index) => {
-                if (item.type === 'text') {
-                    return item.text.startsWith('File context:') ? <> : 
{getDisplayableMessage(item.text, message.type === MessageTypes.AI ? ragCitations : undefined)}
; - } else if (item.type === 'image_url') { + return content.map((item: any, index) => { + if (item.type === 'text' && typeof item.text === 'string') { + if (item.text.startsWith('File context:')) return null; + + const displayableText = getDisplayableMessage(item.text, message.type === MessageTypes.AI ? ragCitations : undefined); + + return ( +
+ {markdownDisplay ? ( + + ) : ( +
{displayableText}
+ )} +
+ ); + } else if (item.type === 'image_url' && item.image_url?.url) { return message.type === MessageTypes.HUMAN ? User provided : @@ -275,10 +298,10 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami }); } return ( -
+
{markdownDisplay ? ( : undefined} > - {renderContent(message.type, message.content, message.metadata)} + {renderContent(message.content, message.metadata)} {showMetadata && !isStreaming && + }} style={isDarkMode ? darkStyles : defaultStyles} /> } {!isStreaming && !messageContainsImage(message.content) &&
} > -
- {renderContent(message.type, message.content)} +
+ {renderContent(message.content)}
)} {message?.type === MessageTypes.TOOL && ( - + + }} style={isDarkMode ? darkStyles : defaultStyles} /> )}
diff --git a/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx b/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx index 47a46d5fc..c1db76ef5 100644 --- a/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx @@ -234,6 +234,7 @@ export default function RagControls ({ isRunning, setUseRag, setRagConfig, ragCo value: repository.repositoryId, label: repository?.repositoryName?.length ? repository?.repositoryName : repository.repositoryId })) || []} + controlId='rag-repository-autosuggest' /> `Use: "${text}"`} onChange={handleCollectionChange} options={collectionOptions} + controlId='rag-collection-autosuggest' /> diff --git a/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx b/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx index 2bbb73dd4..4586b31c9 100644 --- a/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx @@ -16,7 +16,6 @@ import { AttributeEditor, - Box, Container, FormField, Grid, @@ -105,7 +104,7 @@ export const SessionConfiguration = ({ size='large' > - + updateSessionConfiguration('streaming', detail.checked)} checked={chatConfiguration.sessionConfiguration.streaming} @@ -128,11 +127,7 @@ export const SessionConfiguration = ({ Show Message Metadata } {systemConfig && systemConfig.configuration.enabledComponents.editChatHistoryBuffer && !isImageModel && !modelOnly && - - - - + updateSessionConfiguration('ragTopK', parseInt(detail.selectedOption.value))} options={oneThroughTenOptions} /> - } + } {systemConfig && systemConfig.configuration.enabledComponents.editKwargs && !isImageModel && - {!modelOnly && - + {!modelOnly && + +
+ Stop +
{ @@ -391,8 +385,7 @@ export const SessionConfiguration = ({ ]} empty='No stop sequences provided.' /> -
-
+
} {!modelOnly && - -
- - - setSearchQuery(detail.value)} - placeholder='Search sessions by name...' - clearAriaLabel='Clear search' - type='search' - /> - {searchQuery && ( - - Found {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''} - - )} - - } - > - - - - - {config?.configuration.enabledComponents.deleteSessionHistory && - } - -
- } - > +
+ +
History
+ + + setSearchQuery(detail.value)} + placeholder='Search sessions by name...' + clearAriaLabel='Clear search' + type='search' + controlId='session-search-input' + /> + {searchQuery && ( + + Found {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''} + + )} + + } + > + - +
@@ -171,11 +182,18 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) { Details}> - touchFields(['title'])} onChange={({ detail }) => { - setFields({ 'title': detail.value }); - }} - disabled={disabled} - placeholder='Enter template title' /> + touchFields(['title'])} + onChange={({ detail }) => { + setFields({ 'title': detail.value }); + }} + disabled={disabled} + placeholder='Enter template title' + controlId='prompt-template-title-input' + data-testid='prompt-template-title-input' + /> @@ -185,41 +203,51 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) { setFields({'type': detail.selectedOption.value}); }} options={Object.entries(PromptTemplateType).map(([key, value]) => ({label: key, value}))} + data-testid='prompt-template-type-select' /> - { - setSharePublic(detail.checked); - setFields({groups: detail.checked ? ['lisa:public'] : []}); - touchFields(['groups'], ModifyMethod.Unset); - setTokenText(''); - }} - disabled={disabled} /> + { + setSharePublic(detail.checked); + setFields({groups: detail.checked ? ['lisa:public'] : []}); + touchFields(['groups'], ModifyMethod.Unset); + setTokenText(''); + }} + disabled={disabled} + data-testid='prompt-template-share-public-toggle' + /> - { - setTokenText(detail.value); - if (detail.value.length === 0) { - touchFields(['groups'], ModifyMethod.Unset); - } - }} onKeyDown={({detail}) => { - if (detail.keyCode === KeyCode.enter) { - setFields({groups: state.form.groups.concat(`group:${tokenText}`)}); - touchFields(['groups'], ModifyMethod.Unset); - setTokenText(''); - } - }} - onBlur={() => { - if (tokenText.length === 0) { - touchFields(['groups'], ModifyMethod.Unset); - } else { - touchFields(['groups']); - } - }} - placeholder='Enter group name' - disabled={disabled || sharePublic} /> + { + setTokenText(detail.value); + if (detail.value.length === 0) { + touchFields(['groups'], ModifyMethod.Unset); + } + }} onKeyDown={({detail}) => { + if (detail.keyCode === KeyCode.enter) { + setFields({groups: state.form.groups.concat(`group:${tokenText}`)}); + touchFields(['groups'], ModifyMethod.Unset); + setTokenText(''); + } + }} + onBlur={() => { + if (tokenText.length === 0) { + touchFields(['groups'], ModifyMethod.Unset); + } else { + touchFields(['groups']); + } + }} + placeholder='Enter group name' + disabled={disabled || sharePublic} + controlId='prompt-template-groups-input' + /> { const newTokens = [...state.form.groups]; @@ -230,11 +258,16 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) {
-