From ac4c79b5abf73f320888ada063e0411e467e1df5 Mon Sep 17 00:00:00 2001 From: bedanley Date: Tue, 17 Feb 2026 10:42:29 -0700 Subject: [PATCH 01/22] Publish artifacts (#755) * split image build * Add trigger release * Use absolute path for tiktoken script --- .github/workflows/code.publish.yml | 45 ++++++++++++++++++++---------- bin/build-assets | 19 +++++++++++-- lib/api-base/fastApiContainer.ts | 5 ++-- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/.github/workflows/code.publish.yml b/.github/workflows/code.publish.yml index 0c45b6afe..5e406bb25 100644 --- a/.github/workflows/code.publish.yml +++ b/.github/workflows/code.publish.yml @@ -25,6 +25,21 @@ jobs: contents: write # Required for uploading release assets id-token: write # Required for npm trusted publishing (OIDC) steps: + # Free up disk space (~30GB+) by removing preinstalled software we don't need + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo rm -rf /opt/hostedtoolcache/go + sudo rm -rf /opt/hostedtoolcache/Ruby + sudo rm -rf /usr/local/share/powershell + sudo rm -rf /usr/local/share/chromium + sudo rm -rf /usr/local/lib/heroku + sudo rm -rf /usr/share/swift + sudo docker image prune --all --force + df -h - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 # Setup .npmrc file to publish to NpmJs Packages - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4 @@ -37,43 +52,45 @@ jobs: with: python-version: '3.13' + # Install npm dependencies and publish package. Auth is established with NpmJs Trusted publishing. # To update, modify package at https://www.npmjs.com/package/awslabs-lisa/access # More info: https://docs.npmjs.com/trusted-publishers - run: npm ci + # Set version from input when running in test mode + - name: Set test version + if: github.event_name == 'workflow_dispatch' + run: npm version "${{ inputs.version }}" --no-git-tag-version --allow-same-version - name: Publish NPM Package - if: github.event_name == 'release' || !inputs.test_mode + if: "!(github.event_name == 'workflow_dispatch' && inputs.test_mode == true)" run: npm publish - name: Publish NPM Package (Dry Run) - if: github.event_name == 'workflow_dispatch' && inputs.test_mode + if: github.event_name == 'workflow_dispatch' && inputs.test_mode == true run: npm publish --dry-run - - # Build binary assets (lambda layers and container images) - - name: Build Lambda Layers and Container Images - run: | - # Create build directory for lambda layers - mkdir -p build - - # Build assets (runs build-lambdas and build-images --export) - ./bin/build-assets + # Install Python dependencies needed by build scripts + - name: Install Python build dependencies + run: pip install tiktoken==0.12.0 + # Build and export container images (separate from npm publish to avoid OOM) + - name: Build Container Images + run: ./bin/build-images --export env: PYPI_URL: https://pypi.org/simple LISA_VERSION: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.version }} # Upload binary assets to GitHub Release - name: Upload Release Assets - if: github.event_name == 'release' || !inputs.test_mode + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.test_mode == false) uses: softprops/action-gh-release@v2 with: + tag_name: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.version }} files: | - dist/layers/*.zip dist/images/*.tar env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # In test mode, just list what would be uploaded - name: List Build Artifacts (Test Mode) - if: github.event_name == 'workflow_dispatch' && inputs.test_mode + if: github.event_name == 'workflow_dispatch' && inputs.test_mode == true run: | echo "=== Lambda Layers (dist/layers/*.zip) ===" ls -lh dist/layers/*.zip 2>/dev/null || echo "No zip files found" diff --git a/bin/build-assets b/bin/build-assets index 3dcc739fb..8f211f7c2 100755 --- a/bin/build-assets +++ b/bin/build-assets @@ -10,6 +10,17 @@ export PYPI_URL=${PYPI_URL:-"https://pypi.org/simple"} export OUTPUT_DIR=$LAYER_DIR export IMAGE_DIR +# Parse arguments +INCLUDE_IMAGES=false +for arg in "$@"; do + case $arg in + --include-images) + INCLUDE_IMAGES=true + shift + ;; + esac +done + echo "Building all assets..." # Build Lambda layers (Python and Node.js) @@ -24,8 +35,10 @@ mv ./build/Lambda.zip "$LAYER_DIR/" rm -rf ./build cd "$ROOT" -# Build and export container images -echo "Building Image exports..." -./bin/build-images --export +# Build and export container images (only when explicitly requested) +if [[ "$INCLUDE_IMAGES" == "true" ]]; then + echo "Building Image exports..." + ./bin/build-images --export +fi echo "All assets built successfully!" diff --git a/lib/api-base/fastApiContainer.ts b/lib/api-base/fastApiContainer.ts index 57d036d4c..74d1161fa 100644 --- a/lib/api-base/fastApiContainer.ts +++ b/lib/api-base/fastApiContainer.ts @@ -24,7 +24,7 @@ import { dump as yamlDump } from 'js-yaml'; import { ECSCluster, ECSTasks } from './ecsCluster'; import { BaseProps, Ec2Metadata, ECSConfig, EcsSourceType } from '../schema'; import { Vpc } from '../networking/vpc'; -import { REST_API_PATH } from '../util'; +import { REST_API_PATH, ROOT_PATH } from '../util'; import * as child_process from 'child_process'; import * as path from 'path'; import { StringParameter } from 'aws-cdk-lib/aws-ssm'; @@ -128,7 +128,8 @@ export class FastApiContainer extends Construct { // Skip tiktoken cache generation in test environment if (process.env.NODE_ENV !== 'test') { try { - child_process.execSync(`python3 scripts/cache-tiktoken-for-offline.py ${cache_dir}`, { stdio: 'inherit' }); + const scriptPath = path.join(ROOT_PATH, 'scripts', 'cache-tiktoken-for-offline.py'); + child_process.execSync(`python3 ${scriptPath} ${cache_dir}`, { stdio: 'inherit' }); } catch (error) { console.warn('Failed to generate tiktoken cache:', error); // Continue execution even if cache generation fails From 47808561707956eea3cda6b1745491dd77a5da34 Mon Sep 17 00:00:00 2001 From: Ernest-Gray <99225408+Ernest-Gray@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:07:46 -0500 Subject: [PATCH 02/22] updated gitignore (#757) Co-authored-by: Evan Stohlmann --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 343778277..d1e3a8ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ lib/rag/ingestion/ingestion-image/build .DS_Store *.iml *.code-workspace +.hf_token_cache # AI Tools .cursor From 806523dbf71db402010c63d5dc4d9dc084ee9d2b Mon Sep 17 00:00:00 2001 From: Ernest-Gray <99225408+Ernest-Gray@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:38:14 -0500 Subject: [PATCH 03/22] Add VLLM_ASYNC_SCHEDULING environment variable to enable vLLM's async scheduling mode for improved performance. When set to "true", the entrypoint script now passes the --async-scheduling flag to the vLLM server. (#758) --- lib/docs/config/vllm_variables.md | 1 + lib/serve/ecs-model/vllm/src/entrypoint.sh | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/lib/docs/config/vllm_variables.md b/lib/docs/config/vllm_variables.md index 1e409386d..0b7e884b5 100644 --- a/lib/docs/config/vllm_variables.md +++ b/lib/docs/config/vllm_variables.md @@ -27,6 +27,7 @@ LISA Serve supports configuring vLLM model serving through environment variables | `VLLM_MAX_NUM_SEQS` | Maximum concurrent sequences | `256` | `128`, `512` | | `VLLM_ENABLE_PREFIX_CACHING` | Enable prefix caching for repeated prompts | `false` | `true` | | `VLLM_ENABLE_CHUNKED_PREFILL` | Enable chunked prefill | `false` | `true` | +| `VLLM_ASYNC_SCHEDULING` | Adds --async-scheduling for higher performance if hardware supported | `false` | `true` | ## Parallel Processing diff --git a/lib/serve/ecs-model/vllm/src/entrypoint.sh b/lib/serve/ecs-model/vllm/src/entrypoint.sh index b17f1ae27..99422f283 100644 --- a/lib/serve/ecs-model/vllm/src/entrypoint.sh +++ b/lib/serve/ecs-model/vllm/src/entrypoint.sh @@ -43,6 +43,7 @@ declare -a vars=("S3_BUCKET_MODELS" "LOCAL_MODEL_PATH" "MODEL_NAME" "S3_MOUNT_PO # VLLM_BLOCK_SIZE - Memory block size (8/16/32) # VLLM_SEED - Random seed for reproducibility # VLLM_FLOAT32_MATMUL_PRECISION - Float32 matmul precision (ieee/tf32) +# VLLM_ASYNC_SCHEDULING - Adds --async-scheduling for higher performance # # ATTENTION & BACKENDS: # VLLM_ATTENTION_BACKEND - Attention backend (FLASH_ATTN/XFORMERS/ROCM_FLASH/TORCH_SDPA/FLASHINFER/etc) @@ -259,6 +260,11 @@ if [[ -n "${VLLM_TOOL_CALL_PARSER}" ]]; then echo " --tool-call-parser ${VLLM_TOOL_CALL_PARSER}" fi +if [[ "${VLLM_ASYNC_SCHEDULING}" == "true" ]]; then + ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --async-scheduling" + echo " --async-scheduling" +fi + echo "Starting vLLM with args: ${ADDITIONAL_ARGS}" echo "vLLM environment variables:" env | grep -E "^(VLLM_|MAX_TOTAL_TOKENS)=" || echo "No vLLM environment variables set" From 4eae17a345a181e3fd74fdf4d9aa8e9c43943f9b Mon Sep 17 00:00:00 2001 From: bedanley Date: Tue, 17 Feb 2026 13:42:46 -0700 Subject: [PATCH 04/22] Add AMI overrides for ECS (#759) --- ecs_model_deployer/src/lib/ecsCluster.ts | 3 +-- lib/api-base/ecsCluster.ts | 3 +-- lib/schema/configSchema.ts | 2 ++ lib/util/codeFactory.ts | 18 +++++++++++++++++- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/ecs_model_deployer/src/lib/ecsCluster.ts b/ecs_model_deployer/src/lib/ecsCluster.ts index a8451bd2e..91c85494f 100644 --- a/ecs_model_deployer/src/lib/ecsCluster.ts +++ b/ecs_model_deployer/src/lib/ecsCluster.ts @@ -27,7 +27,6 @@ import { Ec2Service, Ec2ServiceProps, Ec2TaskDefinition, - EcsOptimizedImage, HealthCheck, Host, LinuxParameters, @@ -108,7 +107,7 @@ export class ECSCluster extends Construct { const autoScalingGroup = cluster.addCapacity(createCdkId([identifier, 'ASG']), { vpcSubnets: subnetSelection, instanceType: new InstanceType(ecsConfig.instanceType), - machineImage: EcsOptimizedImage.amazonLinux2023(ecsConfig.amiHardwareType), + machineImage: CodeFactory.getEcsMachineImage(config.region, ecsConfig.amiHardwareType, ecsConfig.amiId), minCapacity: ecsConfig.autoScalingConfig.minCapacity, maxCapacity: ecsConfig.autoScalingConfig.maxCapacity, groupMetrics: [GroupMetrics.all()], diff --git a/lib/api-base/ecsCluster.ts b/lib/api-base/ecsCluster.ts index 4b70e6e00..2f367e05a 100644 --- a/lib/api-base/ecsCluster.ts +++ b/lib/api-base/ecsCluster.ts @@ -30,7 +30,6 @@ import { Ec2Service, Ec2ServiceProps, Ec2TaskDefinition, - EcsOptimizedImage, HealthCheck, Host, LinuxParameters, @@ -221,7 +220,7 @@ export class ECSCluster extends Construct { vpc: vpc.vpc, vpcSubnets: vpc.subnetSelection, instanceType: new InstanceType(ecsConfig.instanceType), - machineImage: EcsOptimizedImage.amazonLinux2023(ecsConfig.amiHardwareType), + machineImage: CodeFactory.getEcsMachineImage(config.region, ecsConfig.amiHardwareType, ecsConfig.amiId), minCapacity: ecsConfig.autoScalingConfig.minCapacity, maxCapacity: ecsConfig.autoScalingConfig.maxCapacity, groupMetrics: [GroupMetrics.all()], diff --git a/lib/schema/configSchema.ts b/lib/schema/configSchema.ts index b7d2c1113..0b25e1e37 100644 --- a/lib/schema/configSchema.ts +++ b/lib/schema/configSchema.ts @@ -560,6 +560,8 @@ export type TaskDefinition = z.infer; */ export const EcsBaseConfigSchema = z.object({ amiHardwareType: z.enum(AmiHardwareType).describe('Name of the model.'), + amiId: z.string().optional() + .describe('Optional AMI ID for a custom ECS machine image (e.g. ami-0123456789abcdef0). If not provided, the default ECS-optimized AMI will be used (AL2 for ADC/iso regions, AL2023 otherwise).'), autoScalingConfig: AutoScalingConfigSchema.describe('Configuration for auto scaling settings.'), buildArgs: z.record(z.string(), z.string()).optional() .describe('Optional build args to be applied when creating the task container if containerConfig.image.type is ASSET'), diff --git a/lib/util/codeFactory.ts b/lib/util/codeFactory.ts index f376d535a..f0eca6f5c 100644 --- a/lib/util/codeFactory.ts +++ b/lib/util/codeFactory.ts @@ -19,9 +19,11 @@ import { Code } from 'aws-cdk-lib/aws-lambda'; import { EcsSourceType, ImageAsset } from '../schema'; import { Repository } from 'aws-cdk-lib/aws-ecr'; import { Construct } from 'constructs'; -import { AssetImageProps, ContainerImage } from 'aws-cdk-lib/aws-ecs'; +import { AssetImageProps, ContainerImage, EcsOptimizedImage } from 'aws-cdk-lib/aws-ecs'; import { createCdkId } from '../core/utils'; import { Platform } from 'aws-cdk-lib/aws-ecr-assets'; +import { IMachineImage, MachineImage } from 'aws-cdk-lib/aws-ec2'; +import { AmiHardwareType } from '../schema/cdk'; export const DEFAULT_PLATFORM = Platform.LINUX_AMD64; @@ -87,4 +89,18 @@ export class CodeFactory { } } } + + /** + * Returns the appropriate ECS machine image based on config. + * If a custom AMI ID is provided, uses that. Otherwise falls back to + * the default ECS-optimized AMI (AL2 for ADC/iso regions, AL2023 otherwise). + */ + static getEcsMachineImage (region: string | undefined, amiHardwareType: AmiHardwareType, amiId?: string): IMachineImage { + if (amiId) { + return MachineImage.genericLinux({ [region!]: amiId }); + } + return region?.includes('iso') + ? EcsOptimizedImage.amazonLinux2(amiHardwareType) + : EcsOptimizedImage.amazonLinux2023(amiHardwareType); + } } From c430e50d07f911e8a2311b2f5da223abec55f869 Mon Sep 17 00:00:00 2001 From: Ryan Richmond <32586639+gingerknight@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:01:57 -0700 Subject: [PATCH 05/22] add date/timestamp for sessions and chat prompt --- .../react/src/components/chatbot/Chat.tsx | 28 ++++++++++++++++++- .../chatbot/components/Sessions.tsx | 21 +++++++++----- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index b593dc5df..45c276cf2 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -45,6 +45,7 @@ import { useAttachImageToSessionMutation, useGetSessionHealthQuery, useLazyGetSessionByIdQuery, + useListSessionsQuery, useUpdateSessionMutation, } from '@/shared/reducers/session.reducer'; import { useAppDispatch, useAppSelector } from '@/config/store'; @@ -83,6 +84,7 @@ import { setConfirmationModal } from '@/shared/reducers/modal.reducer'; import ConfirmationModal from '@/shared/modal/confirmation-modal'; import { selectCurrentUsername } from '@/shared/reducers/user.reducer'; import { conditionalDeps } from '../utils'; +import { formatDate } from '@/shared/util/formats'; export default function Chat ({ sessionId }) { const dispatch = useAppDispatch(); @@ -248,6 +250,13 @@ export default function Chat ({ sessionId }) { setRagConfig } = useSession(sessionId, getSessionById); + // Get sessions list lastUpdated timestamp + const { data: sessions } = useListSessionsQuery(null, { refetchOnMountOrArgChange: 5 }); + const currentSessionSummary = useMemo(() => + sessions?.find((s) => s.sessionId === session.sessionId), + [sessions, session.sessionId] + ); + const { modelsOptions, handleModelChange } = useModels( allModels, chatConfiguration, @@ -990,6 +999,16 @@ export default function Chat ({ sessionId }) { openModal={openModal} /> )} + {!loadingSession && session.history.length > 0 && session.lastUpdated && ( + + + Last updated: {new Date(session.lastUpdated).toLocaleString(undefined, { + timeStyle: 'short', + dateStyle: 'medium' + })} + + + )}
@@ -1041,7 +1060,7 @@ export default function Chat ({ sessionId }) { )} - + {enabledServers && enabledServers.length > 0 && selectedModel?.features?.filter((feature) => feature.name === ModelFeatures.TOOL_CALLS)?.length && true ? ( {enabledServers.length} MCP Servers - {openAiTools?.length || 0} tools @@ -1051,6 +1070,13 @@ export default function Chat ({ sessionId }) { : ( This model does not have Tool Calling enabled )} + + {!loadingSession && session.history.length > 0 && (currentSessionSummary?.lastUpdated) && ( + + Last updated: {formatDate(currentSessionSummary?.lastUpdated)} + + )} + {isConnected ? 'Connected' : 'Disconnected'} diff --git a/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx b/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx index 893cfde0a..68ed5e36c 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx @@ -41,6 +41,7 @@ import Box from '@cloudscape-design/components/box'; import JSZip from 'jszip'; import { downloadFile } from '@/shared/util/downloader'; import { setConfirmationModal } from '@/shared/reducers/modal.reducer'; +import { formatDate } from '@/shared/util/formats'; import styles from './Sessions.module.css'; @@ -288,14 +289,20 @@ export function Sessions ({ newSession }) { > - navigate(`/ai-assistant/${item.sessionId}`)}> - - {getSessionDisplay(item, 40)} + + navigate(`/ai-assistant/${item.sessionId}`)}> + + {getSessionDisplay(item, 40)} + + + + {item.lastUpdated ? 'Updated' : 'Created'}: {formatDate(item.lastUpdated || item.startTime)} + - + Date: Wed, 18 Feb 2026 11:52:04 -0700 Subject: [PATCH 06/22] bug: remove duplicate timestamp --- .../react/src/components/chatbot/Chat.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 45c276cf2..2735c1d3c 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -999,16 +999,6 @@ export default function Chat ({ sessionId }) { openModal={openModal} /> )} - {!loadingSession && session.history.length > 0 && session.lastUpdated && ( - - - Last updated: {new Date(session.lastUpdated).toLocaleString(undefined, { - timeStyle: 'short', - dateStyle: 'medium' - })} - - - )}
From 073a79f989aae3ad3faef32541e0fef57c5bbb4f Mon Sep 17 00:00:00 2001 From: Joseph Harold <121983012+jmharold@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:09:09 -0700 Subject: [PATCH 07/22] Feat: file upload enhancements (#761) * PDF and multi-file support for file context * non-rag multifile uploads * add FileTypes and cull allowed extensions * pre-commit * fixing pdf worker import * update FileTypes to use standard MIME types --------- Co-authored-by: jmharold --- .pre-commit-config.yaml | 2 +- lib/user-interface/react/package.json | 1 + .../react/src/components/chatbot/Chat.tsx | 7 +- .../chatbot/components/ChatPromptInput.tsx | 41 ++- .../chatbot/components/FileUploadModals.tsx | 136 ++++++++- .../chatbot/components/Sessions.tsx | 7 +- .../react/src/components/types.tsx | 15 +- package-lock.json | 271 ++++++++++++++++++ 8 files changed, 451 insertions(+), 29 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d44717974..33a78ef94 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,7 +52,7 @@ repos: hooks: - id: codespell entry: codespell - args: ['--skip=*.git*,*cdk.out*,*venv*,*mypy_cache*,*package-lock*,*node_modules*,*dist/*,*poetry.lock*,*coverage*,*models/*,*htmlcov*,*TIKTOKEN_CACHE/*,*test/cdk/stacks/__baselines__/*', "-L=xdescribe,assertIn,afterAll"] + args: ['--skip=*.git*,*cdk.out*,*venv*,*mypy_cache*,*package-lock*,*node_modules*,*dist/*,*/public/*,*poetry.lock*,*coverage*,*models/*,*htmlcov*,*TIKTOKEN_CACHE/*,*test/cdk/stacks/__baselines__/*', "-L=xdescribe,assertIn,afterAll"] pass_filenames: false - repo: https://github.com/pycqa/isort diff --git a/lib/user-interface/react/package.json b/lib/user-interface/react/package.json index b70fa8a42..485013953 100644 --- a/lib/user-interface/react/package.json +++ b/lib/user-interface/react/package.json @@ -43,6 +43,7 @@ "luxon": "^3.7.2", "mermaid": "^11.12.2", "oidc-client-ts": "^3.1.0", + "pdfjs-dist": "^5.4.624", "react": "^19.2.1", "react-ace": "^14.0.1", "react-dom": "^19.2.1", diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 2735c1d3c..1c8cc8fe6 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -127,6 +127,7 @@ export default function Chat ({ sessionId }) { const [userPrompt, setUserPrompt] = useState(''); const [fileContext, setFileContext] = useState(''); const [fileContextName, setFileContextName] = useState(''); + const [fileContextFiles, setFileContextFiles] = useState>([]); const [dirtySession, setDirtySession] = useState(false); const [isConnected, setIsConnected] = useState(false); const [useRag, setUseRag] = useState(false); @@ -784,12 +785,14 @@ export default function Chat ({ sessionId }) { isVideoGenerationMode, fileContext, fileContextName, + fileContextFiles, config, useRag, showMarkdownPreview, setUserPrompt, setFileContext, setFileContextName, + setFileContextFiles, handleAction, handleKeyPress, handleButtonClick, @@ -806,6 +809,7 @@ export default function Chat ({ sessionId }) { isVideoGenerationMode, fileContext, fileContextName, + fileContextFiles, config, useRag, showMarkdownPreview, @@ -862,9 +866,10 @@ export default function Chat ({ sessionId }) { fileContext={fileContext} setFileContext={setFileContext} setFileContextName={setFileContextName} + setFileContextFiles={setFileContextFiles} selectedModel={selectedModel} // eslint-disable-next-line react-hooks/exhaustive-deps - />), conditionalDeps([modals.contextUpload], [modals.contextUpload], [modals.contextUpload, openModal, closeModal, fileContext, setFileContext, setFileContextName, selectedModel]))} + />), conditionalDeps([modals.contextUpload], [modals.contextUpload], [modals.contextUpload, openModal, closeModal, fileContext, setFileContext, setFileContextName, setFileContextFiles, selectedModel]))} {useMemo(() => (; config: IConfiguration; useRag: boolean; showMarkdownPreview: boolean; setUserPrompt: (value: string) => void; setFileContext: (value: string) => void; setFileContextName: (value: string) => void; + setFileContextFiles: React.Dispatch>>; handleAction: () => void; handleKeyPress: (event: any) => void; handleButtonClick: (event: { detail: { id: string } }) => void; @@ -61,18 +62,37 @@ export const ChatPromptInput: React.FC = ({ isImageGenerationMode, isVideoGenerationMode, fileContext, - fileContextName, + fileContextFiles, config, useRag, showMarkdownPreview, setUserPrompt, setFileContext, setFileContextName, + setFileContextFiles, handleAction, handleKeyPress, handleButtonClick, getButtonItems, }) => { + // Handler for removing individual files + const handleRemoveFile = (fileNameToRemove: string) => { + const remainingFiles = fileContextFiles.filter((f) => f.name !== fileNameToRemove); + + if (remainingFiles.length === 0) { + // No files left, clear everything + setFileContext(''); + setFileContextName(''); + setFileContextFiles([]); + } else { + // Update with remaining files + const combinedContext = remainingFiles.map((f) => f.content).join('\n\n'); + const fileNames = remainingFiles.map((f) => f.name).join(', '); + setFileContext(`File context:\n${combinedContext}`); + setFileContextName(fileNames); + setFileContextFiles(remainingFiles); + } + }; return ( = ({
} secondaryContent={ - fileContext && ( + fileContext && fileContextFiles.length > 0 && ( { - setFileContext(''); - setFileContextName(''); + items={fileContextFiles.map((file) => ({ + file: new File([file.content], file.name) + }))} + onDismiss={(event) => { + // The event.detail contains the fileIndex + const dismissedIndex = (event.detail as any).fileIndex; + if (dismissedIndex !== undefined && fileContextFiles[dismissedIndex]) { + handleRemoveFile(fileContextFiles[dismissedIndex].name); + } }} alignment='horizontal' showFileSize={false} showFileLastModified={false} showFileThumbnail={false} i18nStrings={{ - removeFileAriaLabel: () => 'Remove file', + removeFileAriaLabel: (fileIndex) => `Remove file ${fileContextFiles[fileIndex]?.name || fileIndex + 1}`, limitShowFewer: 'Show fewer files', limitShowMore: 'Show more files', errorIconAriaLabel: 'Error', diff --git a/lib/user-interface/react/src/components/chatbot/components/FileUploadModals.tsx b/lib/user-interface/react/src/components/chatbot/components/FileUploadModals.tsx index 11122cef5..6c135349e 100644 --- a/lib/user-interface/react/src/components/chatbot/components/FileUploadModals.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/FileUploadModals.tsx @@ -27,6 +27,8 @@ import { } from '@cloudscape-design/components'; import { FileTypes, StatusTypes } from '@/components/types'; import React, { useState, useEffect } from 'react'; +import * as pdfjsLib from 'pdfjs-dist'; +import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; import { RagConfig } from './RagOptions'; import { useAppDispatch } from '@/config/store'; import { useNotificationService } from '@/shared/util/hooks'; @@ -43,6 +45,64 @@ import { ChunkingConfigForm } from '@/shared/form/ChunkingConfigForm'; import { MetadataForm } from '@/shared/form/MetadataForm'; import { getDisplayName } from '@/shared/util/branding'; +// Configure PDF.js worker to use local file +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker; + +// File extension mappings as fallback if MIME types are not +// specified. Primarily an issue with any compiled languages and +// file types without standard MIME types (Python, YAML, Ruby, Shell). +const AllowedExtensions = + [ + '.ts', '.tsx', '.java', '.c', '.cpp', '.cxx', '.cc', '.h', + '.hpp', '.hxx', '.go', '.rs', '.ps1', '.sql', '.r', '.m', + '.py', '.yml', '.yaml', '.rb', '.sh' + ]; + +// Allowed file types for image-supporting models +const IMAGE_MODEL_FILE_TYPES = [ + FileTypes.TEXT, + FileTypes.JPEG, + FileTypes.PNG, + FileTypes.WEBP, + FileTypes.GIF +]; + +// Allowed file types for text/code models (non-image) +// Note: Python, YAML, Ruby, and Shell files are handled via AllowedExtensions fallback +const TEXT_MODEL_FILE_TYPES = [ + FileTypes.TEXT, + FileTypes.PDF, + FileTypes.JAVASCRIPT, + FileTypes.HTML, + FileTypes.MARKDOWN, + FileTypes.JSON, + FileTypes.CSS, + FileTypes.XML +]; + +// Allowed file types for RAG uploads +const RAG_FILE_TYPES = [ + FileTypes.TEXT, + FileTypes.DOCX, + FileTypes.PDF +]; + +/** + * Extract file extension from filename + */ +const getFileExtension = (filename: string): string => { + const lastDot = filename.lastIndexOf('.'); + return lastDot !== -1 ? filename.slice(lastDot).toLowerCase() : ''; +}; + +/** + * Check if file extension is allowed + */ +const isAllowedFileExtension = (filename: string): boolean => { + const ext = getFileExtension(filename); + return AllowedExtensions.includes(ext); +}; + export const renameFile = (originalFile: File) => { // Add timestamp to filename for RAG uploads to not conflict with existing S3 files const newFileName = `${Date.now()}_${originalFile.name}`; @@ -64,7 +124,8 @@ export const handleUpload = async ( for (let i = 0; i < selectedFiles.length; i++) { const file = selectedFiles[i]; let error = ''; - if (!allowedFileTypes.includes(file.type as FileTypes)) { + // Check both MIME type and file extension (extension as fallback for files without proper MIME types) + if (!allowedFileTypes.includes(file.type as FileTypes) && !isAllowedFileExtension(file.name)) { error = `${file.name} has an unsupported file type for this operation. `; } if (file.size > fileSizeLimit) { @@ -89,6 +150,7 @@ export type ContextUploadProps = { fileContext: string; setFileContext: React.Dispatch>; setFileContextName: React.Dispatch>; + setFileContextFiles: React.Dispatch>>; selectedModel: IModel; }; @@ -98,6 +160,7 @@ export const ContextUploadModal = ({ fileContext, setFileContext, setFileContextName, + setFileContextFiles, selectedModel }: ContextUploadProps) => { const [selectedFiles, setSelectedFiles] = useState([]); @@ -109,19 +172,43 @@ export const ContextUploadModal = ({ useEffect(() => { if (!fileContext) { queueMicrotask(() => setSelectedFiles([])); + setFileContextFiles([]); } - }, [fileContext]); + }, [fileContext, setFileContextFiles]); function handleError (error: string) { notificationService.generateNotification(error, 'error'); } + async function extractTextFromPDF (file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; + + let fullText = ''; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + const pageText = textContent.items + .map((item: any) => item.str) + .join(' '); + fullText += pageText + '\n\n'; + } + + return fullText; + } + + // Store accumulated content in a ref-like object to avoid async state issues + const fileContentsAccumulator: { contents: string[], files: File[] } = { + contents: [], + files: [] + }; + async function processFile (file: File): Promise { - //File context currently only supports single files + // Process file and return its contents to be accumulated let fileContents: string; - if (file.type === FileTypes.JPEG || file.type === FileTypes.JPG || file.type === FileTypes.PNG) { - // Handle JPEG files + // Handle image files + if (IMAGE_MODEL_FILE_TYPES.includes(file.type as FileTypes)) { fileContents = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => { @@ -130,14 +217,17 @@ export const ContextUploadModal = ({ }; reader.readAsDataURL(file); }); + } else if (file.type === FileTypes.PDF) { + // Handle PDF files + fileContents = await extractTextFromPDF(file); } else { // Handle text files fileContents = await file.text(); } - setFileContext(`File context: ${fileContents}`); - setFileContextName(file.name); - setSelectedFiles([file]); + // Accumulate the file content with a clear separator + fileContentsAccumulator.contents.push(`--- File: ${file.name} ---\n${fileContents}`); + fileContentsAccumulator.files.push(file); return true; } @@ -156,9 +246,29 @@ export const ContextUploadModal = ({ +