diff --git a/pages/app-layout/genai-context-panel.page.tsx b/pages/app-layout/genai-context-panel.page.tsx new file mode 100644 index 0000000000..c6bb29f04d --- /dev/null +++ b/pages/app-layout/genai-context-panel.page.tsx @@ -0,0 +1,86 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { AppLayout, Box, ContentLayout, Header, SpaceBetween } from '~components'; +import { AppLayoutProps } from '~components/app-layout'; +import { + createGenaiContextPanelDrawer, + GenaiContextPanelProps, +} from '~components/internal/components/genai-context-panel'; + +import appLayoutLabels from './utils/labels'; + +// [WIP / v0 — AWSUI-52880] Demonstrates the opt-in Global GenAI context panel +// composed into an AppLayout drawer. +const MODELS: ReadonlyArray = [ + { id: 'claude-sonnet', label: 'Claude Sonnet', description: 'Balanced quality and latency' }, + { id: 'claude-haiku', label: 'Claude Haiku', description: 'Fastest, lowest cost' }, + { id: 'nova-pro', label: 'Amazon Nova Pro', description: 'Multimodal reasoning' }, +]; + +function makeFile(name: string, contents: string) { + return new File([contents], name, { type: 'text/plain' }); +} + +export default function GenaiContextPanelPage() { + const [activeDrawerId, setActiveDrawerId] = useState('genai-context-panel'); + const [systemPrompt, setSystemPrompt] = useState( + 'You are a helpful AWS assistant. Prefer concise answers and cite the docs you use.' + ); + const [selectedModelId, setSelectedModelId] = useState('claude-sonnet'); + const [contextFiles, setContextFiles] = useState([ + makeFile('architecture.md', '# Architecture\nsome notes'), + makeFile('costs.csv', 'service,cost\n'), + ]); + + const drawer = createGenaiContextPanelDrawer({ + systemPrompt, + onSystemPromptChange: ({ detail }) => setSystemPrompt(detail.value), + models: MODELS, + selectedModelId, + onSelectedModelChange: ({ detail }) => setSelectedModelId(detail.selectedModelId), + contextFiles, + onContextFilesChange: ({ detail }) => setContextFiles(detail.files), + badge: contextFiles.length > 0, + i18nStrings: { + headerText: 'GenAI context', + headerDescription: 'Configure how the assistant behaves for this conversation.', + systemPromptDescription: 'These instructions are sent with every message.', + modelSelectorDescription: 'Choose which model responds.', + contextFilesDescription: 'Files the assistant can reference.', + }, + }); + + return ( + setActiveDrawerId(detail.activeDrawerId)} + content={ + Global GenAI context panel (WIP v0)}> + + + Open the GenAI context drawer from the right-hand toolbar to view and edit the system prompt, pick + a model, and manage the files currently in the assistant's context. + + + Current system prompt: {systemPrompt} + + + Current model: {selectedModelId} + + + Files in context: {contextFiles.length} + + + + } + /> + ); +} + +// exported for type-checking convenience in this demo +export type { AppLayoutProps }; diff --git a/src/internal/components/genai-context-panel/__tests__/genai-context-panel.test.tsx b/src/internal/components/genai-context-panel/__tests__/genai-context-panel.test.tsx new file mode 100644 index 0000000000..7528292cb6 --- /dev/null +++ b/src/internal/components/genai-context-panel/__tests__/genai-context-panel.test.tsx @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import GenaiContextPanel, { + createGenaiContextPanelDrawer, +} from '../../../../../lib/components/internal/components/genai-context-panel'; + +describe('GenaiContextPanel', () => { + test('renders the header text and current system prompt value', () => { + render(); + + expect(screen.getByText('GenAI context')).toBeInTheDocument(); + expect(screen.getByDisplayValue('be concise')).toBeInTheDocument(); + }); + + test('fires onSystemPromptChange when the prompt is edited', () => { + const onSystemPromptChange = jest.fn(); + render(); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } }); + + expect(onSystemPromptChange).toHaveBeenCalledTimes(1); + expect(onSystemPromptChange.mock.calls[0][0].detail).toEqual({ value: 'hello' }); + }); + + test('shows an empty state when there are no context files', () => { + render(); + + expect(screen.getByText('No files added to the assistant context yet.')).toBeInTheDocument(); + }); + + test('renders a model selector only when models are provided', () => { + const { rerender } = render(); + expect(screen.queryByText('Model')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Model')).toBeInTheDocument(); + }); + + test('fires onContextFilesChange without the removed file when a token is dismissed', () => { + const onContextFilesChange = jest.fn(); + const fileA = new File(['a'], 'a.txt', { type: 'text/plain' }); + const fileB = new File(['b'], 'b.txt', { type: 'text/plain' }); + + render(); + + fireEvent.click(screen.getByLabelText('Remove a.txt (file 1)')); + + expect(onContextFilesChange).toHaveBeenCalledTimes(1); + expect(onContextFilesChange.mock.calls[0][0].detail.files).toEqual([fileB]); + }); +}); + +describe('createGenaiContextPanelDrawer', () => { + test('returns a drawer item with sensible defaults', () => { + const drawer = createGenaiContextPanelDrawer(); + + expect(drawer.id).toBe('genai-context-panel'); + expect(drawer.resizable).toBe(true); + expect(drawer.defaultSize).toBe(360); + expect(drawer.trigger?.iconName).toBe('gen-ai'); + expect(drawer.ariaLabels.drawerName).toBe('GenAI context'); + expect(drawer.content).toBeTruthy(); + }); + + test('uses the header text as the drawer name and forwards a custom id/badge', () => { + const drawer = createGenaiContextPanelDrawer({ + id: 'my-drawer', + badge: true, + i18nStrings: { headerText: 'Assistant context' }, + }); + + expect(drawer.id).toBe('my-drawer'); + expect(drawer.badge).toBe(true); + expect(drawer.ariaLabels.drawerName).toBe('Assistant context'); + }); +}); diff --git a/src/internal/components/genai-context-panel/index.tsx b/src/internal/components/genai-context-panel/index.tsx new file mode 100644 index 0000000000..554cd0cc4f --- /dev/null +++ b/src/internal/components/genai-context-panel/index.tsx @@ -0,0 +1,177 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { AppLayoutProps } from '../../../app-layout/interfaces'; +import InternalBox from '../../../box/internal'; +import InternalFileTokenGroup from '../../../file-token-group/internal'; +import InternalFormField from '../../../form-field/internal'; +import InternalHeader from '../../../header/internal'; +import InternalPromptInput from '../../../prompt-input/internal'; +import { SelectProps } from '../../../select/interfaces'; +import InternalSelect from '../../../select/internal'; +import InternalSpaceBetween from '../../../space-between/internal'; +import { fireNonCancelableEvent } from '../../events'; +import { GenaiContextPanelProps } from './interfaces'; + +export { GenaiContextPanelProps }; + +const DEFAULT_MIN_ROWS = 3; +const DEFAULT_MAX_ROWS = 8; + +/** + * [WIP / v0 — AWSUI-52880] Global GenAI context panel. + * + * A controlled, opt-in composition of existing Cloudscape primitives that gives a + * generative-AI assistant a single place to surface and edit its "context": + * the system prompt, the active model, and the files currently in scope. + * + * It renders plain content and is meant to be dropped into the `content` slot of + * an AppLayout drawer. Use {@link createGenaiContextPanelDrawer} to build a ready + * to use `AppLayoutProps.Drawer` item. + */ +export default function GenaiContextPanel({ + systemPrompt = '', + systemPromptPlaceholder, + systemPromptReadOnly, + systemPromptMinRows = DEFAULT_MIN_ROWS, + systemPromptMaxRows = DEFAULT_MAX_ROWS, + onSystemPromptChange, + models = [], + selectedModelId = null, + onSelectedModelChange, + contextFiles = [], + onContextFilesChange, + i18nStrings = {}, +}: GenaiContextPanelProps) { + const modelOptions: Array = models.map(model => ({ + value: model.id, + label: model.label, + description: model.description, + })); + const selectedOption = modelOptions.find(option => option.value === selectedModelId) ?? null; + + const handleDismissFile = (fileIndex: number) => { + const nextFiles = contextFiles.filter((_, index) => index !== fileIndex); + fireNonCancelableEvent(onContextFilesChange, { files: nextFiles }); + }; + + return ( + + + {i18nStrings.headerText ?? 'Context'} + + + + fireNonCancelableEvent(onSystemPromptChange, { value: detail.value })} + /> + + + {models.length > 0 && ( + + + fireNonCancelableEvent(onSelectedModelChange, { + selectedModelId: detail.selectedOption.value ?? '', + }) + } + /> + + )} + + + {contextFiles.length === 0 ? ( + + {i18nStrings.contextFilesEmptyText ?? 'No files added to the assistant context yet.'} + + ) : ( + ({ file }))} + showFileSize={true} + showFileLastModified={true} + onDismiss={({ detail }) => handleDismissFile(detail.fileIndex)} + i18nStrings={{ + removeFileAriaLabel: + i18nStrings.removeFileAriaLabel ?? + ((fileIndex, fileName) => `Remove ${fileName} (file ${fileIndex + 1})`), + }} + /> + )} + + + ); +} + +export interface CreateGenaiContextPanelDrawerOptions extends GenaiContextPanelProps { + /** + * The id of the drawer. Defaults to `genai-context-panel`. + */ + id?: string; + + /** + * ARIA labels for the drawer. When omitted, the header text (or a sensible + * default) is used for the drawer name. + */ + ariaLabels?: AppLayoutProps.DrawerAriaLabels; + + /** + * Adds a badge to the drawer trigger. + */ + badge?: boolean; + + /** + * Whether the drawer is resizable. Defaults to `true`. + */ + resizable?: boolean; + + /** + * The starting width of the drawer in pixels. Defaults to `360`. + */ + defaultSize?: number; +} + +/** + * Builds an `AppLayoutProps.Drawer` item that renders the GenAI context panel. + * Spread the result into the AppLayout `drawers` array — this keeps the feature + * fully additive and opt-in. + */ +export function createGenaiContextPanelDrawer( + options: CreateGenaiContextPanelDrawerOptions = {} +): AppLayoutProps.Drawer { + const { id = 'genai-context-panel', ariaLabels, badge, resizable = true, defaultSize = 360, ...panelProps } = options; + + const drawerName = panelProps.i18nStrings?.headerText ?? 'GenAI context'; + + return { + id, + badge, + resizable, + defaultSize, + trigger: { iconName: 'gen-ai' }, + ariaLabels: ariaLabels ?? { drawerName, closeButton: 'Close', triggerButton: drawerName, resizeHandle: 'Resize' }, + content: , + }; +} diff --git a/src/internal/components/genai-context-panel/interfaces.ts b/src/internal/components/genai-context-panel/interfaces.ts new file mode 100644 index 0000000000..feffb78a46 --- /dev/null +++ b/src/internal/components/genai-context-panel/interfaces.ts @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { NonCancelableEventHandler } from '../../../types/events'; + +// [WIP / v0 — AWSUI-52880] Global GenAI context panel. +// +// This is an INTERNAL, opt-in composition and is intentionally NOT part of the +// public package API yet. It composes existing Cloudscape primitives to provide +// a reusable "context" surface for a generative-AI assistant (system prompt, +// model selection, and files currently in context), designed to live inside an +// AppLayout drawer. +export interface GenaiContextPanelProps { + /** + * The current system prompt / instructions given to the model. + * The panel is fully controlled — provide `onSystemPromptChange` to update it. + */ + systemPrompt?: string; + + /** + * Placeholder shown in the system prompt editor when it is empty. + */ + systemPromptPlaceholder?: string; + + /** + * When set, the system prompt editor is read-only (view only). + */ + systemPromptReadOnly?: boolean; + + /** + * Minimum number of rows for the system prompt editor. Defaults to `3`. + */ + systemPromptMinRows?: number; + + /** + * Maximum number of rows for the system prompt editor. Defaults to `8`. + */ + systemPromptMaxRows?: number; + + /** + * Called when the user edits the system prompt. + */ + onSystemPromptChange?: NonCancelableEventHandler; + + /** + * The list of models the user can pick from for this conversation. + */ + models?: ReadonlyArray; + + /** + * The id of the currently selected model (matches `Model.id`). + */ + selectedModelId?: string | null; + + /** + * Called when the user selects a different model. + */ + onSelectedModelChange?: NonCancelableEventHandler; + + /** + * Files that are currently part of the assistant's context. Rendered as a + * dismissible list so the user can review and remove them. + */ + contextFiles?: ReadonlyArray; + + /** + * Called when the user removes a file from context. + */ + onContextFilesChange?: NonCancelableEventHandler; + + /** + * An object containing all the localized strings required by the panel. + */ + i18nStrings?: GenaiContextPanelProps.I18nStrings; +} + +export namespace GenaiContextPanelProps { + export interface Model { + id: string; + label: string; + description?: string; + } + + export interface SystemPromptChangeDetail { + value: string; + } + + export interface ModelChangeDetail { + selectedModelId: string; + } + + export interface ContextFilesChangeDetail { + files: File[]; + } + + export interface I18nStrings { + headerText?: string; + headerDescription?: string; + systemPromptLabel?: string; + systemPromptDescription?: string; + modelSelectorLabel?: string; + modelSelectorDescription?: string; + modelSelectorPlaceholder?: string; + contextFilesLabel?: string; + contextFilesDescription?: string; + contextFilesEmptyText?: string; + removeFileAriaLabel?: (fileIndex: number, fileName: string) => string; + } +}