From bb4eabf1559c1ed2e66c08fabade92a3340da7ac Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Thu, 16 Apr 2026 16:45:18 +0530 Subject: [PATCH] refactor: UI code cleanup and folder restructure --- .github/dependabot.yml | 4 +- .github/workflows/docker-build.yml | 12 +- .github/workflows/job-docker-build.yml | 4 +- .gitignore | 12 +- azure_custom.yaml | 2 +- docs/AZD_DEPLOYMENT.md | 4 +- docs/TECHNICAL_GUIDE.md | 2 +- infra/scripts/package_frontend.ps1 | 6 +- infra/scripts/package_frontend.sh | 8 +- scripts/deploy.ps1 | 6 +- scripts/deploy.sh | 6 +- scripts/local_dev.ps1 | 2 +- scripts/local_dev.sh | 2 +- src/{app => App}/.dockerignore | 2 +- src/{app => App}/WebApp.Dockerfile | 14 +- src/{app/frontend => App}/index.html | 0 src/{app/frontend => App}/microsoft.svg | 0 src/{app/frontend => App}/package-lock.json | 102 +++ src/{app/frontend => App}/package.json | 2 + .../server}/package-lock.json | 0 .../server}/package.json | 0 .../frontend-server => App/server}/server.js | 0 src/App/src/App.tsx | 95 +++ src/App/src/api/index.ts | 155 ++++ src/App/src/components/AppHeader.tsx | 66 ++ .../src/components/BriefReview.tsx | 50 +- .../src/components/ChatHistory.tsx | 113 +-- src/App/src/components/ChatInput.tsx | 150 ++++ src/App/src/components/ChatPanel.tsx | 177 ++++ .../src/components/ConfirmedBriefView.tsx | 22 +- .../src/components/InlineContentPreview.tsx | 158 +--- src/App/src/components/MessageBubble.tsx | 118 +++ src/App/src/components/ProductCard.tsx | 127 +++ src/App/src/components/ProductReview.tsx | 121 +++ .../src/components/SelectedProductView.tsx | 64 ++ src/App/src/components/TypingIndicator.tsx | 70 ++ .../src/components/WelcomeCard.tsx | 12 +- src/App/src/hooks/useChatOrchestrator.ts | 185 +++++ src/App/src/hooks/useContentGeneration.ts | 75 ++ src/App/src/hooks/useConversationActions.ts | 178 ++++ src/App/src/hooks/useWindowSize.ts | 16 + src/{app/frontend => App}/src/main.tsx | 10 +- src/App/src/store/appSlice.ts | 87 ++ src/App/src/store/chatSlice.ts | 73 ++ src/App/src/store/contentSlice.ts | 62 ++ src/App/src/store/hooks.ts | 9 + src/App/src/store/store.ts | 19 + .../frontend => App}/src/styles/global.css | 100 --- .../src/styles/images/SamplePrompt.png | Bin .../src/styles/images/contoso.svg | 0 .../src/styles/images/firstprompt.png | Bin .../src/styles/images/secondprompt.png | Bin src/{app/frontend => App}/src/types/index.ts | 31 - src/App/src/utils/constants.ts | 42 + src/App/src/utils/contentParser.ts | 133 +++ src/App/src/utils/errorUtils.ts | 34 + src/App/src/utils/httpClient.ts | 75 ++ src/App/src/utils/imageDownload.ts | 74 ++ src/App/src/utils/messageUtils.ts | 32 + src/{app/frontend => App}/src/vite-env.d.ts | 0 src/{app/frontend => App}/tsconfig.json | 0 src/{app/frontend => App}/tsconfig.node.json | 0 src/{app/frontend => App}/vite.config.ts | 0 src/app/frontend/src/App.tsx | 766 ------------------ src/app/frontend/src/api/index.ts | 252 ------ src/app/frontend/src/components/ChatPanel.tsx | 437 ---------- .../frontend/src/components/ProductReview.tsx | 227 ------ .../src/components/SelectedProductView.tsx | 138 ---- 68 files changed, 2482 insertions(+), 2261 deletions(-) rename src/{app => App}/.dockerignore (96%) rename src/{app => App}/WebApp.Dockerfile (84%) rename src/{app/frontend => App}/index.html (100%) rename src/{app/frontend => App}/microsoft.svg (100%) rename src/{app/frontend => App}/package-lock.json (98%) rename src/{app/frontend => App}/package.json (94%) rename src/{app/frontend-server => App/server}/package-lock.json (100%) rename src/{app/frontend-server => App/server}/package.json (100%) rename src/{app/frontend-server => App/server}/server.js (100%) create mode 100644 src/App/src/App.tsx create mode 100644 src/App/src/api/index.ts create mode 100644 src/App/src/components/AppHeader.tsx rename src/{app/frontend => App}/src/components/BriefReview.tsx (72%) rename src/{app/frontend => App}/src/components/ChatHistory.tsx (87%) create mode 100644 src/App/src/components/ChatInput.tsx create mode 100644 src/App/src/components/ChatPanel.tsx rename src/{app/frontend => App}/src/components/ConfirmedBriefView.tsx (74%) rename src/{app/frontend => App}/src/components/InlineContentPreview.tsx (73%) create mode 100644 src/App/src/components/MessageBubble.tsx create mode 100644 src/App/src/components/ProductCard.tsx create mode 100644 src/App/src/components/ProductReview.tsx create mode 100644 src/App/src/components/SelectedProductView.tsx create mode 100644 src/App/src/components/TypingIndicator.tsx rename src/{app/frontend => App}/src/components/WelcomeCard.tsx (87%) create mode 100644 src/App/src/hooks/useChatOrchestrator.ts create mode 100644 src/App/src/hooks/useContentGeneration.ts create mode 100644 src/App/src/hooks/useConversationActions.ts create mode 100644 src/App/src/hooks/useWindowSize.ts rename src/{app/frontend => App}/src/main.tsx (59%) create mode 100644 src/App/src/store/appSlice.ts create mode 100644 src/App/src/store/chatSlice.ts create mode 100644 src/App/src/store/contentSlice.ts create mode 100644 src/App/src/store/hooks.ts create mode 100644 src/App/src/store/store.ts rename src/{app/frontend => App}/src/styles/global.css (83%) rename src/{app/frontend => App}/src/styles/images/SamplePrompt.png (100%) rename src/{app/frontend => App}/src/styles/images/contoso.svg (100%) rename src/{app/frontend => App}/src/styles/images/firstprompt.png (100%) rename src/{app/frontend => App}/src/styles/images/secondprompt.png (100%) rename src/{app/frontend => App}/src/types/index.ts (78%) create mode 100644 src/App/src/utils/constants.ts create mode 100644 src/App/src/utils/contentParser.ts create mode 100644 src/App/src/utils/errorUtils.ts create mode 100644 src/App/src/utils/httpClient.ts create mode 100644 src/App/src/utils/imageDownload.ts create mode 100644 src/App/src/utils/messageUtils.ts rename src/{app/frontend => App}/src/vite-env.d.ts (100%) rename src/{app/frontend => App}/tsconfig.json (100%) rename src/{app/frontend => App}/tsconfig.node.json (100%) rename src/{app/frontend => App}/vite.config.ts (100%) delete mode 100644 src/app/frontend/src/App.tsx delete mode 100644 src/app/frontend/src/api/index.ts delete mode 100644 src/app/frontend/src/components/ChatPanel.tsx delete mode 100644 src/app/frontend/src/components/ProductReview.tsx delete mode 100644 src/app/frontend/src/components/SelectedProductView.tsx diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a42156bfc..7a32def93 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -35,8 +35,8 @@ updates: # npm dependencies - grouped - package-ecosystem: "npm" directories: - - "/src/app/frontend" - - "/src/app/frontend-server" + - "/src/App" + - "/src/App/server" schedule: interval: "monthly" target-branch: "dependabotchanges" diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 56c978126..2ae42e7cc 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -8,8 +8,8 @@ on: - demo paths: - 'src/backend/**' - - 'src/app/frontend/**' - - 'src/app/frontend-server/**' + - 'src/App/src/**' + - 'src/App/server/**' - '.github/workflows/docker-build.yml' pull_request: types: @@ -23,8 +23,8 @@ on: - demo paths: - 'src/backend/**' - - 'src/app/frontend/**' - - 'src/app/frontend-server/**' + - 'src/App/src/**' + - 'src/App/server/**' - '.github/workflows/docker-build.yml' workflow_dispatch: @@ -88,8 +88,8 @@ jobs: - name: Build and Push Docker Image for Frontend Server uses: docker/build-push-action@v6 with: - context: ./src/app - file: ./src/app/WebApp.Dockerfile + context: ./src/App + file: ./src/App/WebApp.Dockerfile push: ${{ github.ref_name == 'main' || github.ref_name == 'dev' || github.ref_name == 'demo' || github.ref_name == 'dependabotchanges' }} tags: | ${{ secrets.ACR_LOGIN_SERVER || 'acrlogin.azurecr.io' }}/content-gen-app:${{ steps.determine_tag.outputs.tagname }} diff --git a/.github/workflows/job-docker-build.yml b/.github/workflows/job-docker-build.yml index 48266fab7..cfc5b5fe2 100644 --- a/.github/workflows/job-docker-build.yml +++ b/.github/workflows/job-docker-build.yml @@ -65,8 +65,8 @@ jobs: env: DOCKER_BUILD_SUMMARY: false with: - context: ./src/app - file: ./src/app/WebApp.Dockerfile + context: ./src/App + file: ./src/App/WebApp.Dockerfile push: true tags: | ${{ secrets.ACR_TEST_LOGIN_SERVER }}/content-gen-app:${{ steps.generate_docker_tag.outputs.AZURE_ENV_IMAGE_TAG }} diff --git a/.gitignore b/.gitignore index 3696c9d2f..310b95883 100644 --- a/.gitignore +++ b/.gitignore @@ -36,15 +36,15 @@ eggs/ *.swo # Node -/src/app/frontend/node_modules/ -/src/app/frontend-server/node_modules/ -/src/app/frontend-server/static/ -/src/app/frontend-server/*.zip +/src/App/node_modules/ +/src/App/server/node_modules/ +/src/App/server/static/ +/src/App/server/*.zip node_modules/ # Build output -/src/app/static/ -/src/app/frontend/dist/ +/src/App/static/ +/src/App/dist/ # Logs *.log diff --git a/azure_custom.yaml b/azure_custom.yaml index f2a7e530f..6441702c5 100644 --- a/azure_custom.yaml +++ b/azure_custom.yaml @@ -11,7 +11,7 @@ infra: services: frontend: - project: ./src/app/frontend-server + project: ./src/App/server language: js host: appservice dist: ./dist diff --git a/docs/AZD_DEPLOYMENT.md b/docs/AZD_DEPLOYMENT.md index 019483934..f8e8c5f3c 100644 --- a/docs/AZD_DEPLOYMENT.md +++ b/docs/AZD_DEPLOYMENT.md @@ -239,7 +239,7 @@ Error: az acr build failed **Solution**: Check the Dockerfile and ensure all required files are present: ```bash # Manual build for debugging -cd src/app +cd src/App docker build -f WebApp.Dockerfile -t content-gen-app:test . ``` @@ -251,7 +251,7 @@ Error: az webapp deploy failed **Solution**: Ensure the frontend builds successfully: ```bash -cd src/app/frontend +cd src/App npm install npm run build ``` diff --git a/docs/TECHNICAL_GUIDE.md b/docs/TECHNICAL_GUIDE.md index 69a28172b..fcedf6f38 100644 --- a/docs/TECHNICAL_GUIDE.md +++ b/docs/TECHNICAL_GUIDE.md @@ -142,7 +142,7 @@ pip install -r requirements.txt python app.py # Frontend -cd src/app/frontend +cd src/App npm install npm run dev ``` diff --git a/infra/scripts/package_frontend.ps1 b/infra/scripts/package_frontend.ps1 index f711497f6..35888e7a9 100644 --- a/infra/scripts/package_frontend.ps1 +++ b/infra/scripts/package_frontend.ps1 @@ -1,16 +1,16 @@ # Package frontend for App Service deployment # This script is called by AZD during prepackage hook -# Working directory is ./src/app/frontend-server (project directory) +# Working directory is ./src/App/server (project directory) $ErrorActionPreference = 'Stop' Write-Host "Building React frontend..." -ForegroundColor Cyan # Build React frontend (one level up) -Push-Location ../frontend +Push-Location .. npm ci --loglevel=error if ($LASTEXITCODE -ne 0) { throw "npm ci failed with exit code $LASTEXITCODE" } -npm run build -- --outDir ../frontend-server/static +npm run build -- --outDir ./server/static if ($LASTEXITCODE -ne 0) { throw "npm run build failed with exit code $LASTEXITCODE" } Pop-Location diff --git a/infra/scripts/package_frontend.sh b/infra/scripts/package_frontend.sh index 5906d1495..7c25612e2 100644 --- a/infra/scripts/package_frontend.sh +++ b/infra/scripts/package_frontend.sh @@ -3,15 +3,15 @@ set -euo pipefail # Package frontend for App Service deployment # This script is called by AZD during prepackage hook -# Working directory is ./src/app/frontend-server (project directory) +# Working directory is ./src/App/server (project directory) echo "Building React frontend..." # Build React frontend (one level up) -cd ../frontend +cd .. npm ci --loglevel=error -npm run build -- --outDir ../frontend-server/static -cd ../frontend-server +npm run build -- --outDir ./server/static +cd ./server echo "Packaging frontend server..." diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index cc411a2f3..7203524a6 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -131,14 +131,14 @@ if ($continue -eq "y" -or $continue -eq "Y") { Write-Host "Step 3: Building and deploying frontend..." -ForegroundColor Green Write-Host "==========================================" -ForegroundColor Green - Set-Location "$ProjectDir\src\frontend" + Set-Location "$ProjectDir\src\App" npm install npm run build # Copy built files to server directory - Copy-Item -Path "$ProjectDir\src\static\*" -Destination "$ProjectDir\src\frontend-server\static\" -Recurse -Force + Copy-Item -Path "$ProjectDir\src\static\*" -Destination "$ProjectDir\src\App\server\static\" -Recurse -Force - Set-Location "$ProjectDir\src\frontend-server" + Set-Location "$ProjectDir\src\App\server" # Create deployment package if (Test-Path "frontend-deploy.zip") { diff --git a/scripts/deploy.sh b/scripts/deploy.sh index efd2b51b9..34fcc24af 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -129,14 +129,14 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then echo "Step 3: Building and deploying frontend..." echo "==========================================" - cd "$PROJECT_DIR/src/frontend" + cd "$PROJECT_DIR/src/App" npm install npm run build # Copy built files to server directory - cp -r "$PROJECT_DIR/src/static/"* "$PROJECT_DIR/src/frontend-server/static/" + cp -r "$PROJECT_DIR/src/static/"* "$PROJECT_DIR/src/App/server/static/" - cd "$PROJECT_DIR/src/frontend-server" + cd "$PROJECT_DIR/src/App/server" # Create deployment package rm -f frontend-deploy.zip diff --git a/scripts/local_dev.ps1 b/scripts/local_dev.ps1 index 7319aca56..b4d45ba96 100644 --- a/scripts/local_dev.ps1 +++ b/scripts/local_dev.ps1 @@ -31,7 +31,7 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ProjectRoot = Split-Path -Parent $ScriptDir $SrcDir = Join-Path $ProjectRoot "src" $BackendDir = Join-Path $SrcDir "backend" -$FrontendDir = Join-Path $SrcDir "app\frontend" +$FrontendDir = Join-Path $SrcDir "App" # Default ports $BackendPort = if ($env:BACKEND_PORT) { $env:BACKEND_PORT } else { "5000" } diff --git a/scripts/local_dev.sh b/scripts/local_dev.sh index 4f1c084ac..31b342fc8 100644 --- a/scripts/local_dev.sh +++ b/scripts/local_dev.sh @@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" SRC_DIR="$PROJECT_ROOT/src" BACKEND_DIR="$SRC_DIR/backend" -FRONTEND_DIR="$SRC_DIR/app/frontend" +FRONTEND_DIR="$SRC_DIR/App" # Default ports BACKEND_PORT=${BACKEND_PORT:-5000} diff --git a/src/app/.dockerignore b/src/App/.dockerignore similarity index 96% rename from src/app/.dockerignore rename to src/App/.dockerignore index 01b21e59f..42ae7679c 100644 --- a/src/app/.dockerignore +++ b/src/App/.dockerignore @@ -9,7 +9,7 @@ # Build outputs (will be built in container) static/ **/dist/ -frontend-server/static/ +server/static/ # Development files *.log diff --git a/src/app/WebApp.Dockerfile b/src/App/WebApp.Dockerfile similarity index 84% rename from src/app/WebApp.Dockerfile rename to src/App/WebApp.Dockerfile index fca82196e..ead1365fd 100644 --- a/src/app/WebApp.Dockerfile +++ b/src/App/WebApp.Dockerfile @@ -1,7 +1,7 @@ # ============================================ # Frontend Dockerfile # Multi-stage build for Content Generation Frontend -# Combines: frontend (React/Vite) + frontend-server (Node.js proxy) +# Combines: React/Vite frontend + server (Node.js proxy) # ============================================ # ============================================ @@ -11,14 +11,14 @@ FROM node:20-alpine AS frontend-build WORKDIR /app -# Copy frontend package files -COPY frontend/package*.json ./ +# Copy package files +COPY package*.json ./ # Install dependencies RUN npm ci # Copy frontend source code -COPY frontend/ ./ +COPY . ./ # Build the frontend (outputs to ../static, but we're in /app so it goes to /static) # Override outDir to keep it in the container context @@ -31,14 +31,14 @@ FROM node:20-alpine AS production WORKDIR /app -# Copy frontend-server package files -COPY frontend-server/package*.json ./ +# Copy server package files +COPY server/package*.json ./ # Install only production dependencies RUN npm ci --only=production # Copy the server code -COPY frontend-server/server.js ./ +COPY server/server.js ./ # Copy built frontend assets from stage 1 COPY --from=frontend-build /app/dist ./static diff --git a/src/app/frontend/index.html b/src/App/index.html similarity index 100% rename from src/app/frontend/index.html rename to src/App/index.html diff --git a/src/app/frontend/microsoft.svg b/src/App/microsoft.svg similarity index 100% rename from src/app/frontend/microsoft.svg rename to src/App/microsoft.svg diff --git a/src/app/frontend/package-lock.json b/src/App/package-lock.json similarity index 98% rename from src/app/frontend/package-lock.json rename to src/App/package-lock.json index 59a539864..3c616b011 100644 --- a/src/app/frontend/package-lock.json +++ b/src/App/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "@fluentui/react-components": "^9.54.0", "@fluentui/react-icons": "^2.0.245", + "@reduxjs/toolkit": "^2.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", + "react-redux": "^9.1.0", "uuid": "^10.0.0" }, "devDependencies": { @@ -2649,6 +2651,32 @@ "node": ">= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3005,6 +3033,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -3151,6 +3191,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -4439,6 +4485,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5729,6 +5785,30 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5739,6 +5819,22 @@ "node": ">=0.10.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -5772,6 +5868,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/src/app/frontend/package.json b/src/App/package.json similarity index 94% rename from src/app/frontend/package.json rename to src/App/package.json index 2479885d7..48ac03b4c 100644 --- a/src/app/frontend/package.json +++ b/src/App/package.json @@ -12,9 +12,11 @@ "dependencies": { "@fluentui/react-components": "^9.54.0", "@fluentui/react-icons": "^2.0.245", + "@reduxjs/toolkit": "^2.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", + "react-redux": "^9.1.0", "uuid": "^10.0.0" }, "devDependencies": { diff --git a/src/app/frontend-server/package-lock.json b/src/App/server/package-lock.json similarity index 100% rename from src/app/frontend-server/package-lock.json rename to src/App/server/package-lock.json diff --git a/src/app/frontend-server/package.json b/src/App/server/package.json similarity index 100% rename from src/app/frontend-server/package.json rename to src/App/server/package.json diff --git a/src/app/frontend-server/server.js b/src/App/server/server.js similarity index 100% rename from src/app/frontend-server/server.js rename to src/App/server/server.js diff --git a/src/App/src/App.tsx b/src/App/src/App.tsx new file mode 100644 index 000000000..05c668d36 --- /dev/null +++ b/src/App/src/App.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef } from 'react'; + +import { useAppDispatch, useAppSelector } from './store/hooks'; +import { fetchAppConfig, fetchCurrentUser, toggleChatHistory } from './store/appSlice'; +import { ChatPanel } from './components/ChatPanel'; +import { ChatHistory } from './components/ChatHistory'; +import { AppHeader } from './components/AppHeader'; +import { useChatOrchestrator } from './hooks/useChatOrchestrator'; +import { useContentGeneration } from './hooks/useContentGeneration'; +import { useConversationActions } from './hooks/useConversationActions'; + + +function App() { + const dispatch = useAppDispatch(); + + // Select state from Redux store + const { userName, imageGenerationEnabled, showChatHistory } = useAppSelector(state => state.app); + const { conversationId, conversationTitle, messages, isLoading, generationStatus, historyRefreshTrigger } = useAppSelector(state => state.chat); + const { pendingBrief, confirmedBrief, selectedProducts, availableProducts, generatedContent } = useAppSelector(state => state.content); + + // Abort controller for cancelling ongoing requests + const abortControllerRef = useRef(null); + + // Custom hooks for business logic + const { handleSendMessage } = useChatOrchestrator(abortControllerRef); + const { handleGenerateContent } = useContentGeneration(abortControllerRef); + const { + handleBriefConfirm, + handleBriefCancel, + handleProductSelect, + handleStopGeneration, + handleSelectConversation, + handleNewConversation, + } = useConversationActions(abortControllerRef); + + // Fetch app config and user info on mount + useEffect(() => { + dispatch(fetchAppConfig()); + dispatch(fetchCurrentUser()); + }, [dispatch]); + + return ( +
+ {/* Header */} + dispatch(toggleChatHistory())} + /> + + {/* Main Content */} +
+ {/* Chat Panel - main area */} +
+ +
+ + {/* Chat History Sidebar - RIGHT side */} + {showChatHistory && ( +
+ +
+ )} +
+
+ ); +} + +export default App; diff --git a/src/App/src/api/index.ts b/src/App/src/api/index.ts new file mode 100644 index 000000000..1c1b8a8d7 --- /dev/null +++ b/src/App/src/api/index.ts @@ -0,0 +1,155 @@ +/** + * API service for interacting with the Content Generation backend + * Uses centralized httpClient for consistent fetch handling + */ + +import type { + AgentResponse, + AppConfig, + CreativeBrief, + MessageRequest, + MessageResponse, + Product, +} from '../types'; +import { httpClient } from '../utils/httpClient'; +import { POLLING_CONFIG } from '../utils/constants'; + +/** + * Send a message or action to the /api/chat endpoint + */ +export async function sendMessage( + request: MessageRequest, + signal?: AbortSignal +): Promise { + return httpClient.post('/chat', request, signal); +} + +/** + * Get application configuration including feature flags + */ +export async function getAppConfig(): Promise { + return httpClient.get('/config'); +} + +/** + * Request for content generation + */ +export interface GenerateRequest { + conversation_id: string; + user_id: string; + brief: CreativeBrief; + products: Product[]; + generate_images: boolean; +} + +type StageMessageFn = (elapsedSeconds: number) => string; + +/** + * Shared polling loop for task completion + */ +async function* pollLoop( + taskId: string, + signal: AbortSignal | undefined, + getStageMessage: StageMessageFn, +): AsyncGenerator { + const { maxAttempts, intervalMs } = POLLING_CONFIG; + let attempts = 0; + + while (attempts < maxAttempts) { + if (signal?.aborted) { + throw new DOMException('Operation cancelled by user', 'AbortError'); + } + + await new Promise(resolve => setTimeout(resolve, intervalMs)); + attempts++; + + if (signal?.aborted) { + throw new DOMException('Operation cancelled by user', 'AbortError'); + } + + try { + const statusData = await httpClient.get<{ status: string; result?: unknown; error?: string }>( + `/generate/status/${taskId}`, signal + ); + + if (statusData.status === 'completed') { + yield { + type: 'agent_response', + content: JSON.stringify(statusData.result), + is_final: true, + } as AgentResponse; + return; + } + + if (statusData.status === 'failed') { + throw new Error(statusData.error || 'Task failed'); + } + + // Running — yield heartbeat + yield { + type: 'heartbeat', + content: getStageMessage(attempts), + elapsed: attempts, + is_final: false, + } as AgentResponse; + } catch (error) { + // Continue polling on transient errors until max attempts + if (attempts >= maxAttempts) throw error; + } + } + + throw new Error('Task timed out after 10 minutes'); +} + +/** Stage messages for full content generation */ +function generationStageMessage(elapsed: number): string { + if (elapsed < 10) return 'Analyzing creative brief...'; + if (elapsed < 25) return 'Generating marketing copy...'; + if (elapsed < 35) return 'Creating image prompt...'; + if (elapsed < 55) return 'Generating image with AI...'; + if (elapsed < 70) return 'Running compliance check...'; + return 'Finalizing content...'; +} + +/** Stage messages for image regeneration */ +function regenerationStageMessage(elapsed: number): string { + if (elapsed < 10) return 'Starting regeneration...'; + if (elapsed < 30) return 'Generating new image...'; + if (elapsed < 50) return 'Processing image...'; + return 'Finalizing...'; +} + +/** + * Generate content from a confirmed brief + */ +export async function* streamGenerateContent( + request: GenerateRequest, + signal?: AbortSignal +): AsyncGenerator { + const startData = await httpClient.post<{ task_id: string }>('/generate/start', { + brief: request.brief, + products: request.products || [], + generate_images: request.generate_images, + conversation_id: request.conversation_id, + user_id: request.user_id || 'anonymous', + }, signal); + + yield { + type: 'status', + content: 'Generation started...', + is_final: false, + } as AgentResponse; + + yield* pollLoop(startData.task_id, signal, generationStageMessage); +} + +/** + * Poll for task completion using task_id + * Used for both content generation and image regeneration + */ +export async function* pollTaskStatus( + taskId: string, + signal?: AbortSignal +): AsyncGenerator { + yield* pollLoop(taskId, signal, regenerationStageMessage); +} \ No newline at end of file diff --git a/src/App/src/components/AppHeader.tsx b/src/App/src/components/AppHeader.tsx new file mode 100644 index 000000000..dd7205737 --- /dev/null +++ b/src/App/src/components/AppHeader.tsx @@ -0,0 +1,66 @@ +/** + * AppHeader component - application header bar + * Extracted from App.tsx and wrapped with React.memo + */ + +import React from 'react'; +import { + Text, + Avatar, + Button, + Tooltip, + tokens, +} from '@fluentui/react-components'; +import { + History24Regular, + History24Filled, +} from '@fluentui/react-icons'; +import ContosoLogo from '../styles/images/contoso.svg'; + +interface AppHeaderProps { + userName: string; + showChatHistory: boolean; + onToggleChatHistory: () => void; +} + +export const AppHeader = React.memo(function AppHeader({ + userName, + showChatHistory, + onToggleChatHistory, +}: AppHeaderProps) { + return ( +
+
+ Contoso + + Contoso + +
+
+ +
+
+ ); +}); + +AppHeader.displayName = 'AppHeader'; diff --git a/src/app/frontend/src/components/BriefReview.tsx b/src/App/src/components/BriefReview.tsx similarity index 72% rename from src/app/frontend/src/components/BriefReview.tsx rename to src/App/src/components/BriefReview.tsx index 6ea755905..3c1ccb2ec 100644 --- a/src/app/frontend/src/components/BriefReview.tsx +++ b/src/App/src/components/BriefReview.tsx @@ -1,9 +1,11 @@ +import React from 'react'; import { Button, Text, tokens, } from '@fluentui/react-components'; import type { CreativeBrief } from '../types'; +import { AI_DISCLAIMER, BRIEF_FIELD_CONFIG, BRIEF_FIELD_KEYS, BRIEF_FIELD_LABELS } from '../utils/constants'; interface BriefReviewProps { brief: CreativeBrief; @@ -12,47 +14,17 @@ interface BriefReviewProps { isAwaitingResponse?: boolean; } -// Mapping of field keys to user-friendly labels for the 9 key areas -const fieldLabels: Record = { - overview: 'Overview', - objectives: 'Objectives', - target_audience: 'Target Audience', - key_message: 'Key Message', - tone_and_style: 'Tone and Style', - deliverable: 'Deliverable', - timelines: 'Timelines', - visual_guidelines: 'Visual Guidelines', - cta: 'Call to Action', -}; - -export function BriefReview({ +export const BriefReview = React.memo(function BriefReview({ brief, onConfirm, onStartOver, isAwaitingResponse = false, }: BriefReviewProps) { - const allFields: (keyof CreativeBrief)[] = [ - 'overview', 'objectives', 'target_audience', 'key_message', - 'tone_and_style', 'deliverable', 'timelines', 'visual_guidelines', 'cta' - ]; - const populatedFields = allFields.filter(key => brief[key]?.trim()).length; - const missingFields = allFields.filter(key => !brief[key]?.trim()); - - // Define the order and labels for display in the card - const displayOrder: { key: keyof CreativeBrief; label: string }[] = [ - { key: 'overview', label: 'Campaign Objective' }, - { key: 'objectives', label: 'Objectives' }, - { key: 'target_audience', label: 'Target Audience' }, - { key: 'key_message', label: 'Key Message' }, - { key: 'tone_and_style', label: 'Tone & Style' }, - { key: 'visual_guidelines', label: 'Visual Guidelines' }, - { key: 'deliverable', label: 'Deliverables' }, - { key: 'timelines', label: 'Timelines' }, - { key: 'cta', label: 'Call to Action' }, - ]; + const populatedFields = BRIEF_FIELD_KEYS.filter(key => brief[key]?.trim()).length; + const missingFields = BRIEF_FIELD_KEYS.filter(key => !brief[key]?.trim()); - // Filter to only populated fields - const populatedDisplayFields = displayOrder.filter(({ key }) => brief[key]?.trim()); + // Filter to only populated fields for display + const populatedDisplayFields = BRIEF_FIELD_CONFIG.filter(({ key }) => brief[key]?.trim()); return (
I've captured {populatedFields} of 9 key areas. Would you like to add more details? - You are missing: {missingFields.map(f => fieldLabels[f]).join(', ')}. + You are missing: {missingFields.map(f => BRIEF_FIELD_LABELS[f]).join(', ')}.

You can tell me things like:
    @@ -177,9 +149,11 @@ export function BriefReview({ paddingTop: '8px', }}> - AI-generated content may be incorrect + {AI_DISCLAIMER}
); -} +}); + +BriefReview.displayName = 'BriefReview'; diff --git a/src/app/frontend/src/components/ChatHistory.tsx b/src/App/src/components/ChatHistory.tsx similarity index 87% rename from src/app/frontend/src/components/ChatHistory.tsx rename to src/App/src/components/ChatHistory.tsx index f258d2a48..6ccab6eeb 100644 --- a/src/app/frontend/src/components/ChatHistory.tsx +++ b/src/App/src/components/ChatHistory.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Button, Text, @@ -26,6 +26,10 @@ import { Edit20Regular, DismissCircle20Regular, } from '@fluentui/react-icons'; +import { httpClient } from '../utils/httpClient'; + +const MIN_NAME_LENGTH = 5; +const MAX_NAME_LENGTH = 50; interface ConversationSummary { id: string; @@ -45,7 +49,7 @@ interface ChatHistoryProps { isGenerating?: boolean; // True when content generation is in progress } -export function ChatHistory({ +export const ChatHistory = React.memo(function ChatHistory({ currentConversationId, currentConversationTitle, currentMessages = [], @@ -65,18 +69,12 @@ export function ChatHistory({ const handleClearAllConversations = useCallback(async () => { setIsClearing(true); try { - const response = await fetch('/api/conversations', { - method: 'DELETE', - }); - if (response.ok) { - setConversations([]); - onNewConversation(); - setIsClearAllDialogOpen(false); - } else { - console.error('Failed to clear all conversations'); - } - } catch (err) { - console.error('Error clearing all conversations:', err); + await httpClient.delete('/conversations'); + setConversations([]); + onNewConversation(); + setIsClearAllDialogOpen(false); + } catch { + // Clear failed — no action needed } finally { setIsClearing(false); } @@ -84,41 +82,24 @@ export function ChatHistory({ const handleDeleteConversation = useCallback(async (conversationId: string) => { try { - const response = await fetch(`/api/conversations/${conversationId}`, { - method: 'DELETE', - }); - if (response.ok) { - setConversations(prev => prev.filter(c => c.id !== conversationId)); - if (conversationId === currentConversationId) { - onNewConversation(); - } - } else { - console.error('Failed to delete conversation'); + await httpClient.delete(`/conversations/${conversationId}`); + setConversations(prev => prev.filter(c => c.id !== conversationId)); + if (conversationId === currentConversationId) { + onNewConversation(); } - } catch (err) { - console.error('Error deleting conversation:', err); + } catch { + // Delete failed — no action needed } }, [currentConversationId, onNewConversation]); const handleRenameConversation = useCallback(async (conversationId: string, newTitle: string) => { try { - const response = await fetch(`/api/conversations/${conversationId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ title: newTitle }), - }); - - if (response.ok) { - setConversations(prev => prev.map(c => - c.id === conversationId ? { ...c, title: newTitle } : c - )); - } else { - console.error('Failed to rename conversation'); - } - } catch (err) { - console.error('Error renaming conversation:', err); + await httpClient.put(`/conversations/${conversationId}`, { title: newTitle }); + setConversations(prev => prev.map(c => + c.id === conversationId ? { ...c, title: newTitle } : c + )); + } catch { + // Rename failed — no action needed } }, []); @@ -126,17 +107,9 @@ export function ChatHistory({ setIsLoading(true); setError(null); try { - // Backend gets user from auth headers, no need to pass user_id - const response = await fetch('/api/conversations'); - if (response.ok) { - const data = await response.json(); - setConversations(data.conversations || []); - } else { - // If no conversations endpoint, use empty list - setConversations([]); - } - } catch (err) { - console.error('Error loading conversations:', err); + const data = await httpClient.get<{ conversations: ConversationSummary[] }>('/conversations'); + setConversations(data.conversations || []); + } catch { setError('Unable to load conversation history'); setConversations([]); } finally { @@ -379,7 +352,9 @@ export function ChatHistory({ ); -} +}); + +ChatHistory.displayName = 'ChatHistory'; interface ConversationItemProps { conversation: ConversationSummary; @@ -391,7 +366,7 @@ interface ConversationItemProps { disabled?: boolean; } -function ConversationItem({ +const ConversationItem = React.memo(function ConversationItem({ conversation, isActive, onSelect, @@ -418,12 +393,12 @@ function ConversationItem({ const trimmedValue = renameValue.trim(); // Validate before API call - if (trimmedValue.length < 5) { - setRenameError('Conversation name must be at least 5 characters'); + if (trimmedValue.length < MIN_NAME_LENGTH) { + setRenameError(`Conversation name must be at least ${MIN_NAME_LENGTH} characters`); return; } - if (trimmedValue.length > 50) { - setRenameError('Conversation name cannot exceed 50 characters'); + if (trimmedValue.length > MAX_NAME_LENGTH) { + setRenameError(`Conversation name cannot exceed ${MAX_NAME_LENGTH} characters`); return; } if (!/[a-zA-Z0-9]/.test(trimmedValue)) { @@ -550,18 +525,18 @@ function ConversationItem({ { const newValue = e.target.value; setRenameValue(newValue); if (newValue.trim() === '') { setRenameError('Conversation name cannot be empty or contain only spaces'); - } else if (newValue.trim().length < 5) { - setRenameError('Conversation name must be at least 5 characters'); + } else if (newValue.trim().length < MIN_NAME_LENGTH) { + setRenameError(`Conversation name must be at least ${MIN_NAME_LENGTH} characters`); } else if (!/[a-zA-Z0-9]/.test(newValue)) { setRenameError('Conversation name must contain at least one letter or number'); - } else if (newValue.length > 50) { - setRenameError('Conversation name cannot exceed 50 characters'); + } else if (newValue.length > MAX_NAME_LENGTH) { + setRenameError(`Conversation name cannot exceed ${MAX_NAME_LENGTH} characters`); } else { setRenameError(''); } @@ -584,7 +559,7 @@ function ConversationItem({ display: 'block' }} > - Maximum 50 characters ({renameValue.length}/50) + Maximum {MAX_NAME_LENGTH} characters ({renameValue.length}/{MAX_NAME_LENGTH}) {renameError && ( 50} + disabled={renameValue.trim().length < MIN_NAME_LENGTH || !/[a-zA-Z0-9]/.test(renameValue) || renameValue.length > MAX_NAME_LENGTH} > Rename @@ -640,4 +615,6 @@ function ConversationItem({ ); -} +}); + +ConversationItem.displayName = 'ConversationItem'; diff --git a/src/App/src/components/ChatInput.tsx b/src/App/src/components/ChatInput.tsx new file mode 100644 index 000000000..b51983af2 --- /dev/null +++ b/src/App/src/components/ChatInput.tsx @@ -0,0 +1,150 @@ +/** + * ChatInput component - message input with send/new-chat buttons + * Extracted from ChatPanel.tsx and wrapped with React.memo + */ + +import React, { useState } from 'react'; +import { + Text, + Button, + Tooltip, + tokens, +} from '@fluentui/react-components'; +import { + Send20Regular, + Add20Regular, +} from '@fluentui/react-icons'; +import { AI_DISCLAIMER } from '../utils/constants'; + +interface ChatInputProps { + onSendMessage: (message: string) => void; + isLoading: boolean; + hasMessages: boolean; + onNewConversation?: () => void; + initialValue?: string; + onInputChange?: (value: string) => void; +} + +export const ChatInput = React.memo(function ChatInput({ + onSendMessage, + isLoading, + hasMessages, + onNewConversation, + initialValue = '', + onInputChange, +}: ChatInputProps) { + const [inputValue, setInputValue] = useState(initialValue); + + // Sync external value changes (e.g., from suggestion clicks) + React.useEffect(() => { + setInputValue(initialValue); + }, [initialValue]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (inputValue.trim() && !isLoading) { + onSendMessage(inputValue.trim()); + setInputValue(''); + onInputChange?.(''); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + onInputChange?.(e.target.value); + }; + + return ( +
+ {/* Input Box */} +
+ { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }} + placeholder="Type a message" + disabled={isLoading} + style={{ + flex: 1, + border: 'none', + outline: 'none', + backgroundColor: 'transparent', + fontFamily: 'var(--fontFamilyBase)', + fontSize: '14px', + color: tokens.colorNeutralForeground1, + }} + /> + + {/* Icons on the right */} +
+ +
+
+ + {/* Disclaimer */} + + {AI_DISCLAIMER} + +
+ ); +}); + +ChatInput.displayName = 'ChatInput'; diff --git a/src/App/src/components/ChatPanel.tsx b/src/App/src/components/ChatPanel.tsx new file mode 100644 index 000000000..ae83ddddf --- /dev/null +++ b/src/App/src/components/ChatPanel.tsx @@ -0,0 +1,177 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import type { ChatMessage, CreativeBrief, Product, GeneratedContent } from '../types'; +import { BriefReview } from './BriefReview'; +import { ConfirmedBriefView } from './ConfirmedBriefView'; +import { SelectedProductView } from './SelectedProductView'; +import { ProductReview } from './ProductReview'; +import { InlineContentPreview } from './InlineContentPreview'; +import { WelcomeCard } from './WelcomeCard'; +import { MessageBubble } from './MessageBubble'; +import { TypingIndicator } from './TypingIndicator'; +import { ChatInput } from './ChatInput'; + +interface ChatPanelProps { + messages: ChatMessage[]; + onSendMessage: (message: string) => void; + isLoading: boolean; + generationStatus?: string; + onStopGeneration?: () => void; + // Inline component props + pendingBrief?: CreativeBrief | null; + confirmedBrief?: CreativeBrief | null; + generatedContent?: GeneratedContent | null; + selectedProducts?: Product[]; + availableProducts?: Product[]; + onBriefConfirm?: () => void; + onBriefCancel?: () => void; + onGenerateContent?: () => void; + onRegenerateContent?: () => void; + onProductSelect?: (product: Product) => void; + // Feature flags + imageGenerationEnabled?: boolean; + // New chat + onNewConversation?: () => void; +} + +export const ChatPanel = React.memo(function ChatPanel({ + messages, + onSendMessage, + isLoading, + generationStatus, + onStopGeneration, + pendingBrief, + confirmedBrief, + generatedContent, + selectedProducts = [], + availableProducts = [], + onBriefConfirm, + onBriefCancel, + onGenerateContent, + onRegenerateContent, + onProductSelect, + imageGenerationEnabled = true, + onNewConversation, +}: ChatPanelProps) { + const [inputValue, setInputValue] = useState(''); + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, pendingBrief, confirmedBrief, generatedContent, isLoading, generationStatus]); + + // Determine if we should show inline components + const showBriefReview = !!(pendingBrief && onBriefConfirm && onBriefCancel); + const showProductReview = !!(confirmedBrief && !generatedContent && onGenerateContent); + const showContentPreview = !!(generatedContent && onRegenerateContent); + const showWelcome = messages.length === 0 && !showBriefReview && !showProductReview && !showContentPreview; + + // Handle suggestion click from welcome card + const handleSuggestionClick = useCallback((prompt: string) => { + setInputValue(prompt); + }, []); + + const handleSendMessage = useCallback((msg: string) => { + onSendMessage(msg); + setInputValue(''); + }, [onSendMessage]); + + return ( +
+ {/* Messages Area */} +
+ {showWelcome ? ( + + ) : ( + <> + {messages.map((message) => ( + + ))} + + {/* Brief Review - Read Only with Conversational Prompts */} + {showBriefReview && ( + + )} + + {/* Confirmed Brief View - Persistent read-only view */} + {confirmedBrief && !pendingBrief && ( + + )} + + {/* Selected Product View - Persistent read-only view after content generation */} + {generatedContent && selectedProducts.length > 0 && ( + + )} + + {/* Product Review - Conversational Product Selection */} + {showProductReview && ( + + )} + + {/* Inline Content Preview */} + {showContentPreview && ( + 0 ? selectedProducts[0] : undefined} + imageGenerationEnabled={imageGenerationEnabled} + /> + )} + + {/* Loading/Typing Indicator */} + {isLoading && ( + + )} + + )} + +
+
+ + {/* Input Area */} + 0} + onNewConversation={onNewConversation} + /> +
+ ); +}); + +ChatPanel.displayName = 'ChatPanel'; + + diff --git a/src/app/frontend/src/components/ConfirmedBriefView.tsx b/src/App/src/components/ConfirmedBriefView.tsx similarity index 74% rename from src/app/frontend/src/components/ConfirmedBriefView.tsx rename to src/App/src/components/ConfirmedBriefView.tsx index e7feb9416..a1d7ce1fc 100644 --- a/src/app/frontend/src/components/ConfirmedBriefView.tsx +++ b/src/App/src/components/ConfirmedBriefView.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Text, Badge, @@ -7,24 +8,13 @@ import { Checkmark20Regular, } from '@fluentui/react-icons'; import type { CreativeBrief } from '../types'; +import { BRIEF_FIELD_CONFIG } from '../utils/constants'; interface ConfirmedBriefViewProps { brief: CreativeBrief; } -const briefFields: { key: keyof CreativeBrief; label: string }[] = [ - { key: 'overview', label: 'Overview' }, - { key: 'objectives', label: 'Objectives' }, - { key: 'target_audience', label: 'Target Audience' }, - { key: 'key_message', label: 'Key Message' }, - { key: 'tone_and_style', label: 'Tone & Style' }, - { key: 'deliverable', label: 'Deliverable' }, - { key: 'timelines', label: 'Timelines' }, - { key: 'visual_guidelines', label: 'Visual Guidelines' }, - { key: 'cta', label: 'Call to Action' }, -]; - -export function ConfirmedBriefView({ brief }: ConfirmedBriefViewProps) { +export const ConfirmedBriefView = React.memo(function ConfirmedBriefView({ brief }: ConfirmedBriefViewProps) { return (
- {briefFields.map(({ key, label }) => { + {BRIEF_FIELD_CONFIG.map(({ key, label }) => { const value = brief[key]; if (!value?.trim()) return null; @@ -88,4 +78,6 @@ export function ConfirmedBriefView({ brief }: ConfirmedBriefViewProps) {
); -} +}); + +ConfirmedBriefView.displayName = 'ConfirmedBriefView'; diff --git a/src/app/frontend/src/components/InlineContentPreview.tsx b/src/App/src/components/InlineContentPreview.tsx similarity index 73% rename from src/app/frontend/src/components/InlineContentPreview.tsx rename to src/App/src/components/InlineContentPreview.tsx index 3ee0eead2..7f39825bb 100644 --- a/src/app/frontend/src/components/InlineContentPreview.tsx +++ b/src/App/src/components/InlineContentPreview.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Button, Text, @@ -22,6 +22,10 @@ import { ShieldError20Regular, } from '@fluentui/react-icons'; import type { GeneratedContent, ComplianceViolation, Product } from '../types'; +import { AI_DISCLAIMER, PRODUCT_DEFAULTS } from '../utils/constants'; +import { useWindowSize } from '../hooks/useWindowSize'; +import { isContentFilterError, getErrorMessage, copyToClipboard } from '../utils/errorUtils'; +import { downloadImageWithBanner } from '../utils/imageDownload'; interface InlineContentPreviewProps { content: GeneratedContent; @@ -31,20 +35,7 @@ interface InlineContentPreviewProps { imageGenerationEnabled?: boolean; } -// Custom hook for responsive breakpoints -function useWindowSize() { - const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200); - - useEffect(() => { - const handleResize = () => setWindowWidth(window.innerWidth); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - return windowWidth; -} - -export function InlineContentPreview({ +export const InlineContentPreview = React.memo(function InlineContentPreview({ content, onRegenerate, isLoading, @@ -57,32 +48,6 @@ export function InlineContentPreview({ const isSmall = windowWidth < 768; - // Helper to detect content filter errors - const isContentFilterError = (errorMessage?: string): boolean => { - if (!errorMessage) return false; - const filterPatterns = [ - 'content_filter', 'ContentFilter', 'content management policy', - 'ResponsibleAI', 'responsible_ai_policy', 'content filtering', - 'filtered', 'safety system', 'self_harm', 'sexual', 'violence', 'hate', - ]; - return filterPatterns.some(pattern => - errorMessage.toLowerCase().includes(pattern.toLowerCase()) - ); - }; - - const getErrorMessage = (errorMessage?: string): { title: string; description: string } => { - if (isContentFilterError(errorMessage)) { - return { - title: 'Content Filtered', - description: 'Your request was blocked by content safety filters. Please try modifying your creative brief.', - }; - } - return { - title: 'Generation Failed', - description: errorMessage || 'An error occurred. Please try again.', - }; - }; - const handleCopyText = () => { const textToCopy = [ text_content?.headline && `✨ ${text_content.headline} ✨`, @@ -90,103 +55,18 @@ export function InlineContentPreview({ text_content?.tagline, ].filter(Boolean).join('\n\n'); - navigator.clipboard.writeText(textToCopy); + copyToClipboard(textToCopy); setCopied(true); setTimeout(() => setCopied(false), 2000); }; - const handleDownloadImage = async () => { + const handleDownloadImage = () => { if (!image_content?.image_url) return; - - try { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const img = new Image(); - img.crossOrigin = 'anonymous'; - - img.onload = () => { - // Calculate banner height - const bannerHeight = Math.max(60, img.height * 0.1); - const padding = Math.max(16, img.width * 0.03); - - // Set canvas size to include bottom banner - canvas.width = img.width; - canvas.height = img.height + bannerHeight; - - // Draw the image at the top - ctx.drawImage(img, 0, 0); - - // Draw white banner at the bottom - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, img.height, img.width, bannerHeight); - - // Draw banner border line - ctx.strokeStyle = '#e5e5e5'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, img.height); - ctx.lineTo(img.width, img.height); - ctx.stroke(); - - // Draw text in the banner - const headlineText = selectedProduct?.product_name || text_content?.headline || 'Your Product'; - const headlineFontSize = Math.max(18, Math.min(36, img.width * 0.04)); - const taglineText = text_content?.tagline || ''; - const taglineFontSize = Math.max(12, Math.min(20, img.width * 0.025)); - - // Draw headline - ctx.font = `600 ${headlineFontSize}px Georgia, serif`; - ctx.fillStyle = '#1a1a1a'; - ctx.fillText(headlineText, padding, img.height + padding + headlineFontSize * 0.8, img.width - padding * 2); - - // Draw tagline if available - if (taglineText) { - ctx.font = `400 italic ${taglineFontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`; - ctx.fillStyle = '#666666'; - ctx.fillText(taglineText, padding, img.height + padding + headlineFontSize + taglineFontSize * 0.8 + 4, img.width - padding * 2); - } - - canvas.toBlob((blob) => { - if (blob) { - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = 'generated-marketing-image.png'; - link.click(); - URL.revokeObjectURL(url); - } - }, 'image/png'); - }; - - img.onerror = () => { - if (image_content?.image_url) { - const link = document.createElement('a'); - link.href = image_content.image_url; - link.download = 'generated-image.png'; - link.click(); - } - }; - - img.src = image_content.image_url; - } catch { - if (image_content?.image_url) { - const link = document.createElement('a'); - link.href = image_content.image_url; - link.download = 'generated-image.png'; - link.click(); - } - } + const headline = selectedProduct?.product_name || text_content?.headline || 'Your Product'; + downloadImageWithBanner(image_content.image_url, headline, text_content?.tagline); }; - // Get product display name - const getProductDisplayName = () => { - if (selectedProduct) { - return selectedProduct.product_name; - } - return text_content?.headline || 'Your Content'; - }; + const productDisplayName = selectedProduct?.product_name || text_content?.headline || 'Your Content'; return (
- ✨ Discover the serene elegance of {getProductDisplayName()}. + ✨ Discover the serene elegance of {productDisplayName}. )} @@ -299,7 +179,7 @@ export function InlineContentPreview({
{image_content.alt_text - AI-generated content may be incorrect + {AI_DISCLAIMER} {/* Collapsible Compliance Section */} @@ -512,9 +392,11 @@ export function InlineContentPreview({ )}
); -} +}); + +InlineContentPreview.displayName = 'InlineContentPreview'; -function ViolationCard({ violation }: { violation: ComplianceViolation }) { +const ViolationCard = React.memo(function ViolationCard({ violation }: { violation: ComplianceViolation }) { const getSeverityStyles = () => { switch (violation.severity) { case 'error': @@ -558,4 +440,6 @@ function ViolationCard({ violation }: { violation: ComplianceViolation }) {
); -} +}); + +ViolationCard.displayName = 'ViolationCard'; diff --git a/src/App/src/components/MessageBubble.tsx b/src/App/src/components/MessageBubble.tsx new file mode 100644 index 000000000..73d8b11ab --- /dev/null +++ b/src/App/src/components/MessageBubble.tsx @@ -0,0 +1,118 @@ +/** + * MessageBubble component - displays a single chat message + * Extracted from ChatPanel.tsx and wrapped with React.memo + */ + +import React, { useState } from 'react'; +import { + Text, + Badge, + Button, + Tooltip, + tokens, +} from '@fluentui/react-components'; +import { Copy20Regular } from '@fluentui/react-icons'; +import ReactMarkdown from 'react-markdown'; +import type { ChatMessage } from '../types'; +import { AI_DISCLAIMER } from '../utils/constants'; +import { copyToClipboard } from '../utils/errorUtils'; + +interface MessageBubbleProps { + message: ChatMessage; +} + +export const MessageBubble = React.memo(function MessageBubble({ message }: MessageBubbleProps) { + const isUser = message.role === 'user'; + const [copied, setCopied] = useState(false); + + const onCopy = () => { + copyToClipboard(message.content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {/* Agent badge for assistant messages */} + {!isUser && message.agent && ( + + {message.agent} + + )} + + {/* Message content with markdown */} +
+ + {message.content} + + + {/* Footer for assistant messages */} + {!isUser && ( +
+ + {AI_DISCLAIMER} + + +
+ +
+
+ )} +
+
+ ); +}); + +MessageBubble.displayName = 'MessageBubble'; diff --git a/src/App/src/components/ProductCard.tsx b/src/App/src/components/ProductCard.tsx new file mode 100644 index 000000000..f5c520caf --- /dev/null +++ b/src/App/src/components/ProductCard.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { Text, tokens } from '@fluentui/react-components'; +import { Box20Regular } from '@fluentui/react-icons'; +import type { Product } from '../types'; +import { PRODUCT_DEFAULTS } from '../utils/constants'; + +interface ProductCardProps { + product: Product; + /** Image/icon size in px */ + size?: 'small' | 'medium'; + /** Optional selection border */ + isSelected?: boolean; + /** Card click handler (omit for read-only cards) */ + onClick?: () => void; + disabled?: boolean; +} + +const SIZE_MAP = { + small: { img: 56, radius: '6px', textSize: 300 as const, priceSize: 200 as const }, + medium: { img: 80, radius: '8px', textSize: 400 as const, priceSize: 300 as const }, +}; + +export const ProductCard = React.memo(function ProductCard({ + product, + size = 'medium', + isSelected = false, + onClick, + disabled = false, +}: ProductCardProps) { + const { img, radius, textSize, priceSize } = SIZE_MAP[size]; + + return ( +
+ {/* Product image or fallback icon */} + {product.image_url ? ( + {product.product_name} + ) : ( +
+ +
+ )} + + {/* Product info */} +
+ + {product.product_name} + + + {product.tags || product.description || PRODUCT_DEFAULTS.fallbackTags} + + + ${product.price?.toFixed(2) || PRODUCT_DEFAULTS.fallbackPrice.toFixed(2)} USD + +
+
+ ); +}); + +ProductCard.displayName = 'ProductCard'; diff --git a/src/App/src/components/ProductReview.tsx b/src/App/src/components/ProductReview.tsx new file mode 100644 index 000000000..2e10db7e1 --- /dev/null +++ b/src/App/src/components/ProductReview.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { + Button, + Text, + tokens, +} from '@fluentui/react-components'; +import { + Sparkle20Regular, +} from '@fluentui/react-icons'; +import type { Product } from '../types'; +import { AI_DISCLAIMER } from '../utils/constants'; +import { ProductCard } from './ProductCard'; + +interface ProductReviewProps { + products: Product[]; + onConfirm: () => void; + isAwaitingResponse?: boolean; + availableProducts?: Product[]; + onProductSelect?: (product: Product) => void; + disabled?: boolean; +} + +export const ProductReview = React.memo(function ProductReview({ + products, + onConfirm, + isAwaitingResponse = false, + availableProducts = [], + onProductSelect, + disabled = false, +}: ProductReviewProps) { + const displayProducts = availableProducts.length > 0 ? availableProducts : products; + const selectedProductIds = new Set(products.map(p => p.sku || p.product_name)); + + return ( +
+
+ + Here is the list of available paints: + +
+ + {displayProducts.length > 0 ? ( +
+ {displayProducts.map((product, index) => ( + onProductSelect?.(product)} + disabled={disabled} + /> + ))} +
+ ) : ( +
+ + No products available. + +
+ )} + + {displayProducts.length > 0 && ( +
+ + {products.length === 0 && ( + + Select a product to continue + + )} +
+ )} + +
+ + {AI_DISCLAIMER} + +
+
+ ); +}); + +ProductReview.displayName = 'ProductReview'; diff --git a/src/App/src/components/SelectedProductView.tsx b/src/App/src/components/SelectedProductView.tsx new file mode 100644 index 000000000..f2427c14e --- /dev/null +++ b/src/App/src/components/SelectedProductView.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { + Badge, + tokens, +} from '@fluentui/react-components'; +import { + Checkmark20Regular, +} from '@fluentui/react-icons'; +import type { Product } from '../types'; +import { ProductCard } from './ProductCard'; + +interface SelectedProductViewProps { + products: Product[]; +} + +export const SelectedProductView = React.memo(function SelectedProductView({ products }: SelectedProductViewProps) { + if (products.length === 0) return null; + + return ( +
+
+ } + > + Products Selected + +
+ +
+ {products.map((product, index) => ( + + ))} +
+
+ ); +}); + +SelectedProductView.displayName = 'SelectedProductView'; diff --git a/src/App/src/components/TypingIndicator.tsx b/src/App/src/components/TypingIndicator.tsx new file mode 100644 index 000000000..3a436ce76 --- /dev/null +++ b/src/App/src/components/TypingIndicator.tsx @@ -0,0 +1,70 @@ +/** + * TypingIndicator component - shows loading/thinking state + * Extracted from ChatPanel.tsx and wrapped with React.memo + */ + +import React from 'react'; +import { + Text, + Button, + Tooltip, + tokens, +} from '@fluentui/react-components'; +import { Stop24Regular } from '@fluentui/react-icons'; + +interface TypingIndicatorProps { + generationStatus?: string; + onStopGeneration?: () => void; +} + +export const TypingIndicator = React.memo(function TypingIndicator({ + generationStatus, + onStopGeneration, +}: TypingIndicatorProps) { + return ( +
+
+ + + + + +
+ + {generationStatus || 'Thinking...'} + + {onStopGeneration && ( + + + + )} +
+ ); +}); + +TypingIndicator.displayName = 'TypingIndicator'; diff --git a/src/app/frontend/src/components/WelcomeCard.tsx b/src/App/src/components/WelcomeCard.tsx similarity index 87% rename from src/app/frontend/src/components/WelcomeCard.tsx rename to src/App/src/components/WelcomeCard.tsx index ab5740708..2188aca99 100644 --- a/src/app/frontend/src/components/WelcomeCard.tsx +++ b/src/App/src/components/WelcomeCard.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Card, Text, @@ -7,19 +8,16 @@ import FirstPromptIcon from '../styles/images/firstprompt.png'; import SecondPromptIcon from '../styles/images/secondprompt.png'; interface SuggestionCard { - title: string; prompt: string; icon: string; } const suggestions: SuggestionCard[] = [ { - title: "I need to create a social media post about paint products for home remodels. The campaign is titled \"Brighten Your Springtime\" and the audience is new homeowners. I need marketing copy plus an image. The image should be an informal living room with tasteful furnishings.", prompt: "I need to create a social media post about paint products for home remodels. The campaign is titled \"Brighten Your Springtime\" and the audience is new homeowners. I need marketing copy plus an image. The image should be an informal living room with tasteful furnishings.", icon: FirstPromptIcon, }, { - title: "Generate a social media campaign with ad copy and an image. This is for \"Back to School\" and the audience is parents of school age children. Tone is playful and humorous. The image must have minimal kids accessories in a children's bedroom. Show the room in a wide view.", prompt: "Generate a social media campaign with ad copy and an image. This is for \"Back to School\" and the audience is parents of school age children. Tone is playful and humorous. The image must have minimal kids accessories in a children's bedroom. Show the room in a wide view.", icon: SecondPromptIcon, } @@ -30,7 +28,7 @@ interface WelcomeCardProps { currentInput?: string; } -export function WelcomeCard({ onSuggestionClick, currentInput = '' }: WelcomeCardProps) { +export const WelcomeCard = React.memo(function WelcomeCard({ onSuggestionClick, currentInput = '' }: WelcomeCardProps) { const selectedIndex = suggestions.findIndex(s => s.prompt === currentInput); return (
- {suggestion.title} + {suggestion.prompt}
@@ -155,4 +153,6 @@ export function WelcomeCard({ onSuggestionClick, currentInput = '' }: WelcomeCar ); -} +}); + +WelcomeCard.displayName = 'WelcomeCard'; diff --git a/src/App/src/hooks/useChatOrchestrator.ts b/src/App/src/hooks/useChatOrchestrator.ts new file mode 100644 index 000000000..0853ba159 --- /dev/null +++ b/src/App/src/hooks/useChatOrchestrator.ts @@ -0,0 +1,185 @@ +/** + * Custom hook for chat message orchestration + * Extracts handleSendMessage logic from App.tsx + */ + +import { useCallback } from 'react'; +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + addMessage, + setIsLoading, + setGenerationStatus, + incrementHistoryRefresh, + setConversationTitle, +} from '../store/chatSlice'; +import { + setPendingBrief, + setConfirmedBrief, + setSelectedProducts, + setAvailableProducts, + setGeneratedContent, +} from '../store/contentSlice'; +import { sendMessage, pollTaskStatus } from '../api'; +import { createMessage, createErrorMessage, createCancelMessage } from '../utils/messageUtils'; +import { parseEventContent, mergeRegenerationResult } from '../utils/contentParser'; +import type { CreativeBrief, Product, GeneratedContent } from '../types'; + +export function useChatOrchestrator( + abortControllerRef: React.MutableRefObject +) { + const dispatch = useAppDispatch(); + const conversationId = useAppSelector(state => state.chat.conversationId); + const conversationTitle = useAppSelector(state => state.chat.conversationTitle); + const userId = useAppSelector(state => state.app.userId); + const confirmedBrief = useAppSelector(state => state.content.confirmedBrief); + const selectedProducts = useAppSelector(state => state.content.selectedProducts); + const availableProducts = useAppSelector(state => state.content.availableProducts); + const generatedContent = useAppSelector(state => state.content.generatedContent); + + const handleSendMessage = useCallback(async (content: string) => { + const userMessage = createMessage('user', content); + dispatch(addMessage(userMessage)); + dispatch(setIsLoading(true)); + dispatch(setGenerationStatus('Processing your request...')); + + // Create new abort controller for this request + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + try { + let productsToSend = selectedProducts; + if (generatedContent && confirmedBrief && availableProducts.length > 0) { + const contentLower = content.toLowerCase(); + const mentionedProduct = availableProducts.find(p => + contentLower.includes(p.product_name.toLowerCase()) + ); + if (mentionedProduct && mentionedProduct.product_name !== selectedProducts[0]?.product_name) { + productsToSend = [mentionedProduct]; + } + } + + // Send message with context + const response = await sendMessage({ + conversation_id: conversationId, + user_id: userId, + message: content, + ...(confirmedBrief && { brief: confirmedBrief }), + ...(productsToSend.length > 0 && { selected_products: productsToSend }), + ...(generatedContent && { has_generated_content: true }), + }, signal); + + // Handle response based on action_type + switch (response.action_type) { + case 'brief_parsed': { + const brief = response.data?.brief as CreativeBrief | undefined; + const title = response.data?.generated_title as string | undefined; + if (brief) dispatch(setPendingBrief(brief)); + if (title && !conversationTitle) dispatch(setConversationTitle(title)); + break; + } + + case 'clarification_needed': { + const brief = response.data?.brief as CreativeBrief | undefined; + if (brief) dispatch(setPendingBrief(brief)); + break; + } + + case 'brief_confirmed': { + const brief = response.data?.brief as CreativeBrief | undefined; + const products = response.data?.products as Product[] | undefined; + if (brief) { + dispatch(setConfirmedBrief(brief)); + dispatch(setPendingBrief(null)); + } + if (products) dispatch(setAvailableProducts(products)); + break; + } + + case 'products_selected': { + const products = response.data?.products as Product[] | undefined; + if (products) dispatch(setSelectedProducts(products)); + break; + } + + case 'content_generated': + case 'image_regenerated': { + const gc = response.data?.generated_content as GeneratedContent | undefined; + if (gc) dispatch(setGeneratedContent(gc)); + break; + } + + case 'regeneration_started': { + const taskId = response.data?.task_id as string; + if (!taskId) throw new Error('No task_id received for regeneration'); + + dispatch(setGenerationStatus('Regenerating image...')); + + for await (const event of pollTaskStatus(taskId, signal)) { + if (event.type === 'heartbeat') { + const statusMessage = (event.content as string) || 'Regenerating image...'; + const elapsed = (event as { elapsed?: number }).elapsed || 0; + dispatch(setGenerationStatus(elapsed > 0 ? `${statusMessage} (${elapsed}s)` : statusMessage)); + } else if (event.type === 'agent_response' && event.is_final) { + const result = parseEventContent(event.content); + + // Update selected products if backend provided new ones + const newProducts = result?.selected_products as Product[] | undefined; + if (newProducts && newProducts.length > 0) { + dispatch(setSelectedProducts(newProducts)); + } + + // Update confirmed brief if backend provided an updated one + const updatedBrief = result?.updated_brief as CreativeBrief | undefined; + if (updatedBrief) dispatch(setConfirmedBrief(updatedBrief)); + + // Merge regeneration result with existing generated content + dispatch(setGeneratedContent(mergeRegenerationResult(result, generatedContent))); + } else if (event.type === 'error') { + throw new Error(event.content || 'Regeneration failed'); + } + } + + dispatch(setGenerationStatus('')); + break; + } + + case 'start_over': { + dispatch(setPendingBrief(null)); + dispatch(setConfirmedBrief(null)); + dispatch(setSelectedProducts([])); + dispatch(setGeneratedContent(null)); + break; + } + + case 'rai_blocked': + case 'error': + case 'chat_response': + default: + break; + } + + // Add assistant message from response + if (response.message) { + dispatch(addMessage(createMessage('assistant', response.message))); + } + + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + dispatch(addMessage(createCancelMessage())); + } else { + dispatch(addMessage(createErrorMessage())); + } + } finally { + dispatch(setIsLoading(false)); + dispatch(setGenerationStatus('')); + abortControllerRef.current = null; + dispatch(incrementHistoryRefresh()); + } + }, [ + dispatch, conversationId, userId, conversationTitle, + confirmedBrief, selectedProducts, generatedContent, + availableProducts, abortControllerRef, + ]); + + return { handleSendMessage }; +} diff --git a/src/App/src/hooks/useContentGeneration.ts b/src/App/src/hooks/useContentGeneration.ts new file mode 100644 index 000000000..d8f34e283 --- /dev/null +++ b/src/App/src/hooks/useContentGeneration.ts @@ -0,0 +1,75 @@ +/** + * Custom hook for content generation orchestration + * Extracts handleGenerateContent logic from App.tsx + */ + +import { useCallback } from 'react'; +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + addMessage, + setIsLoading, + setGenerationStatus, +} from '../store/chatSlice'; +import { setGeneratedContent } from '../store/contentSlice'; +import { streamGenerateContent } from '../api'; +import { createErrorMessage, createCancelMessage } from '../utils/messageUtils'; +import { parseGeneratedContent, parseEventContent } from '../utils/contentParser'; + +export function useContentGeneration( + abortControllerRef: React.MutableRefObject +) { + const dispatch = useAppDispatch(); + const conversationId = useAppSelector(state => state.chat.conversationId); + const userId = useAppSelector(state => state.app.userId); + const confirmedBrief = useAppSelector(state => state.content.confirmedBrief); + const selectedProducts = useAppSelector(state => state.content.selectedProducts); + const imageGenerationEnabled = useAppSelector(state => state.app.imageGenerationEnabled); + + const handleGenerateContent = useCallback(async () => { + if (!confirmedBrief) return; + + dispatch(setIsLoading(true)); + dispatch(setGenerationStatus('Starting content generation...')); + + // Create new abort controller for this request + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + try { + for await (const event of streamGenerateContent({ + conversation_id: conversationId, + user_id: userId, + brief: confirmedBrief, + products: selectedProducts, + generate_images: imageGenerationEnabled, + }, signal)) { + + if (event.type === 'heartbeat') { + const statusMessage = (event.content as string) || 'Generating content...'; + const elapsed = (event as { elapsed?: number }).elapsed || 0; + dispatch(setGenerationStatus(elapsed > 0 ? `${statusMessage} (${elapsed}s)` : statusMessage)); + } else if (event.type === 'agent_response' && event.is_final) { + const result = parseEventContent(event.content); + const generated = parseGeneratedContent(result); + dispatch(setGeneratedContent(generated)); + } else if (event.type === 'error') { + throw new Error(event.content || 'Generation failed'); + } + } + + dispatch(setGenerationStatus('')); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + dispatch(addMessage(createCancelMessage('Content generation stopped.'))); + } else { + dispatch(addMessage(createErrorMessage('Sorry, there was an error generating content. Please try again.'))); + } + } finally { + dispatch(setIsLoading(false)); + dispatch(setGenerationStatus('')); + abortControllerRef.current = null; + } + }, [dispatch, confirmedBrief, selectedProducts, conversationId, userId, imageGenerationEnabled, abortControllerRef]); + + return { handleGenerateContent }; +} diff --git a/src/App/src/hooks/useConversationActions.ts b/src/App/src/hooks/useConversationActions.ts new file mode 100644 index 000000000..7a2e370aa --- /dev/null +++ b/src/App/src/hooks/useConversationActions.ts @@ -0,0 +1,178 @@ +/** + * Custom hook for conversation action handlers + * Extracts brief confirm/cancel, product selection, conversation management from App.tsx + */ + +import { useCallback } from 'react'; +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + addMessage, + setConversationId, + setConversationTitle, + setMessages, + resetChat, +} from '../store/chatSlice'; +import { + setPendingBrief, + setConfirmedBrief, + setSelectedProducts, + setAvailableProducts, + setGeneratedContent, + resetContent, +} from '../store/contentSlice'; +import { sendMessage } from '../api'; +import { httpClient } from '../utils/httpClient'; +import { createMessage } from '../utils/messageUtils'; +import { restoreGeneratedContent } from '../utils/contentParser'; +import type { ChatMessage, CreativeBrief, Product } from '../types'; + +async function fetchProducts(): Promise { + try { + const data = await httpClient.get<{ products: Product[] }>('/products'); + return data.products || []; + } catch { + return []; + } +} + +export function useConversationActions( + abortControllerRef: React.MutableRefObject +) { + const dispatch = useAppDispatch(); + const conversationId = useAppSelector(state => state.chat.conversationId); + const userId = useAppSelector(state => state.app.userId); + const pendingBrief = useAppSelector(state => state.content.pendingBrief); + const availableProducts = useAppSelector(state => state.content.availableProducts); + const selectedProducts = useAppSelector(state => state.content.selectedProducts); + + const handleBriefConfirm = useCallback(async () => { + if (!pendingBrief) return; + + try { + const response = await sendMessage({ + conversation_id: conversationId, + user_id: userId, + action: 'confirm_brief', + brief: pendingBrief, + }); + + // Update state based on response + if (response.action_type === 'brief_confirmed') { + const brief = response.data?.brief as CreativeBrief | undefined; + if (brief) { + dispatch(setConfirmedBrief(brief)); + } else { + dispatch(setConfirmedBrief(pendingBrief)); + } + dispatch(setPendingBrief(null)); + + // Fetch products separately after confirmation + const products = await fetchProducts(); + dispatch(setAvailableProducts(products)); + } + + // Add assistant message + if (response.message) { + dispatch(addMessage(createMessage('assistant', response.message, 'ProductAgent'))); + } + } catch { + // Brief confirmation failed — no action needed + } + }, [dispatch, conversationId, userId, pendingBrief]); + + const handleBriefCancel = useCallback(async () => { + dispatch(setPendingBrief(null)); + dispatch(addMessage( + createMessage('assistant', 'No problem. Please provide your creative brief again or ask me any questions.') + )); + }, [dispatch]); + + const handleProductSelect = useCallback((product: Product) => { + const isSelected = selectedProducts.some( + p => (p.sku || p.product_name) === (product.sku || product.product_name) + ); + + if (isSelected) { + dispatch(setSelectedProducts([])); + } else { + // Single selection mode + dispatch(setSelectedProducts([product])); + } + }, [dispatch, selectedProducts]); + + const handleStopGeneration = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }, [abortControllerRef]); + + const handleSelectConversation = useCallback(async (selectedConversationId: string) => { + try { + const data = await httpClient.get>(`/conversations/${selectedConversationId}?user_id=${encodeURIComponent(userId)}`); + dispatch(setConversationId(selectedConversationId)); + dispatch(setConversationTitle(null)); + + const loadedMessages: ChatMessage[] = ((data.messages as Array<{ role: string; content: string; timestamp?: string; agent?: string }>) || []).map( + (msg, index: number) => ({ + id: `${selectedConversationId}-${index}`, + role: msg.role as 'user' | 'assistant', + content: msg.content, + timestamp: msg.timestamp || new Date().toISOString(), + agent: msg.agent, + }) + ); + dispatch(setMessages(loadedMessages)); + + // Only set confirmedBrief if the brief was actually confirmed + const metadata = data.metadata as Record | undefined; + const briefWasConfirmed = metadata?.brief_confirmed || data.generated_content; + if (briefWasConfirmed && data.brief) { + dispatch(setConfirmedBrief(data.brief as CreativeBrief)); + dispatch(setPendingBrief(null)); + } else if (data.brief) { + dispatch(setPendingBrief(data.brief as CreativeBrief)); + dispatch(setConfirmedBrief(null)); + } else { + dispatch(setPendingBrief(null)); + dispatch(setConfirmedBrief(null)); + } + + // Restore availableProducts for product/color name detection + if (data.brief && availableProducts.length === 0) { + const products = await fetchProducts(); + dispatch(setAvailableProducts(products)); + } + + if (data.generated_content) { + const gc = data.generated_content as Record; + dispatch(setGeneratedContent(restoreGeneratedContent(gc))); + + const selectedProds = gc.selected_products; + if (Array.isArray(selectedProds) && selectedProds.length > 0) { + dispatch(setSelectedProducts(selectedProds as Product[])); + } else { + dispatch(setSelectedProducts([])); + } + } else { + dispatch(setGeneratedContent(null)); + dispatch(setSelectedProducts([])); + } + } catch { + // Conversation load failed — no action needed + } + }, [dispatch, userId, availableProducts.length]); + + const handleNewConversation = useCallback(() => { + dispatch(resetChat()); + dispatch(resetContent()); + }, [dispatch]); + + return { + handleBriefConfirm, + handleBriefCancel, + handleProductSelect, + handleStopGeneration, + handleSelectConversation, + handleNewConversation, + }; +} diff --git a/src/App/src/hooks/useWindowSize.ts b/src/App/src/hooks/useWindowSize.ts new file mode 100644 index 000000000..3ceffbccc --- /dev/null +++ b/src/App/src/hooks/useWindowSize.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from 'react'; + +/** Returns current window width, updating on resize. */ +export function useWindowSize(): number { + const [windowWidth, setWindowWidth] = useState( + typeof window !== 'undefined' ? window.innerWidth : 1200 + ); + + useEffect(() => { + const handleResize = () => setWindowWidth(window.innerWidth); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return windowWidth; +} diff --git a/src/app/frontend/src/main.tsx b/src/App/src/main.tsx similarity index 59% rename from src/app/frontend/src/main.tsx rename to src/App/src/main.tsx index e59c93df9..462ebe3e9 100644 --- a/src/app/frontend/src/main.tsx +++ b/src/App/src/main.tsx @@ -1,13 +1,17 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { Provider } from 'react-redux'; +import { store } from './store/store'; import App from './App'; import './styles/global.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + ); diff --git a/src/App/src/store/appSlice.ts b/src/App/src/store/appSlice.ts new file mode 100644 index 000000000..ebf882e79 --- /dev/null +++ b/src/App/src/store/appSlice.ts @@ -0,0 +1,87 @@ +/** + * App slice - manages application-level state + * (user info, feature flags, UI toggles) + */ + +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import type { AppConfig } from '../types'; +import { getAppConfig } from '../api'; +import { httpClient } from '../utils/httpClient'; + +interface AppState { + userId: string; + userName: string; + imageGenerationEnabled: boolean; + showChatHistory: boolean; +} + +const initialState: AppState = { + userId: '', + userName: '', + imageGenerationEnabled: true, + showChatHistory: true, +}; + +export const fetchAppConfig = createAsyncThunk( + 'app/fetchConfig', + async () => { + const config: AppConfig = await getAppConfig(); + return config; + } +); + +export const fetchCurrentUser = createAsyncThunk( + 'app/fetchCurrentUser', + async () => { + try { + const payload = await httpClient.fetchExternal; + }>>('/.auth/me'); + const userClaims = payload[0]?.user_claims || []; + const objectIdClaim = userClaims.find( + (claim) => + claim.typ === 'http://schemas.microsoft.com/identity/claims/objectidentifier' + ); + const nameClaim = userClaims.find( + (claim) => claim.typ === 'name' + ); + return { + userId: objectIdClaim?.val || 'anonymous', + userName: nameClaim?.val || '', + }; + } catch { + return { userId: 'anonymous', userName: '' }; + } + } +); + +const appSlice = createSlice({ + name: 'app', + initialState, + reducers: { + toggleChatHistory(state) { + state.showChatHistory = !state.showChatHistory; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchAppConfig.fulfilled, (state, action) => { + state.imageGenerationEnabled = action.payload.enable_image_generation; + }) + .addCase(fetchAppConfig.rejected, (state) => { + // Default to enabled if config fetch fails + state.imageGenerationEnabled = true; + }) + .addCase(fetchCurrentUser.fulfilled, (state, action) => { + state.userId = action.payload.userId; + state.userName = action.payload.userName; + }) + .addCase(fetchCurrentUser.rejected, (state) => { + state.userId = 'anonymous'; + state.userName = ''; + }); + }, +}); + +export const { toggleChatHistory } = appSlice.actions; +export default appSlice.reducer; diff --git a/src/App/src/store/chatSlice.ts b/src/App/src/store/chatSlice.ts new file mode 100644 index 000000000..e754fb412 --- /dev/null +++ b/src/App/src/store/chatSlice.ts @@ -0,0 +1,73 @@ +/** + * Chat slice - manages conversation and messaging state + */ + +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { v4 as uuidv4 } from 'uuid'; +import type { ChatMessage } from '../types'; + +interface ChatState { + conversationId: string; + conversationTitle: string | null; + messages: ChatMessage[]; + isLoading: boolean; + generationStatus: string; + historyRefreshTrigger: number; +} + +const initialState: ChatState = { + conversationId: uuidv4(), + conversationTitle: null, + messages: [], + isLoading: false, + generationStatus: '', + historyRefreshTrigger: 0, +}; + +const chatSlice = createSlice({ + name: 'chat', + initialState, + reducers: { + setConversationId(state, action: PayloadAction) { + state.conversationId = action.payload; + }, + setConversationTitle(state, action: PayloadAction) { + state.conversationTitle = action.payload; + }, + addMessage(state, action: PayloadAction) { + state.messages.push(action.payload); + }, + setMessages(state, action: PayloadAction) { + state.messages = action.payload; + }, + setIsLoading(state, action: PayloadAction) { + state.isLoading = action.payload; + }, + setGenerationStatus(state, action: PayloadAction) { + state.generationStatus = action.payload; + }, + incrementHistoryRefresh(state) { + state.historyRefreshTrigger += 1; + }, + resetChat(state) { + state.conversationId = uuidv4(); + state.conversationTitle = null; + state.messages = []; + state.isLoading = false; + state.generationStatus = ''; + }, + }, +}); + +export const { + setConversationId, + setConversationTitle, + addMessage, + setMessages, + setIsLoading, + setGenerationStatus, + incrementHistoryRefresh, + resetChat, +} = chatSlice.actions; + +export default chatSlice.reducer; diff --git a/src/App/src/store/contentSlice.ts b/src/App/src/store/contentSlice.ts new file mode 100644 index 000000000..09b4ba7fe --- /dev/null +++ b/src/App/src/store/contentSlice.ts @@ -0,0 +1,62 @@ +/** + * Content slice - manages creative brief, product selection, and generated content state + */ + +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { CreativeBrief, Product, GeneratedContent } from '../types'; + +interface ContentState { + pendingBrief: CreativeBrief | null; + confirmedBrief: CreativeBrief | null; + selectedProducts: Product[]; + availableProducts: Product[]; + generatedContent: GeneratedContent | null; +} + +const initialState: ContentState = { + pendingBrief: null, + confirmedBrief: null, + selectedProducts: [], + availableProducts: [], + generatedContent: null, +}; + +const contentSlice = createSlice({ + name: 'content', + initialState, + reducers: { + setPendingBrief(state, action: PayloadAction) { + state.pendingBrief = action.payload; + }, + setConfirmedBrief(state, action: PayloadAction) { + state.confirmedBrief = action.payload; + }, + setSelectedProducts(state, action: PayloadAction) { + state.selectedProducts = action.payload; + }, + setAvailableProducts(state, action: PayloadAction) { + state.availableProducts = action.payload; + }, + setGeneratedContent(state, action: PayloadAction) { + state.generatedContent = action.payload; + }, + resetContent(state) { + state.pendingBrief = null; + state.confirmedBrief = null; + state.selectedProducts = []; + state.availableProducts = []; + state.generatedContent = null; + }, + }, +}); + +export const { + setPendingBrief, + setConfirmedBrief, + setSelectedProducts, + setAvailableProducts, + setGeneratedContent, + resetContent, +} = contentSlice.actions; + +export default contentSlice.reducer; diff --git a/src/App/src/store/hooks.ts b/src/App/src/store/hooks.ts new file mode 100644 index 000000000..4139570b7 --- /dev/null +++ b/src/App/src/store/hooks.ts @@ -0,0 +1,9 @@ +/** + * Typed Redux hooks for use throughout the application + */ + +import { useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/App/src/store/store.ts b/src/App/src/store/store.ts new file mode 100644 index 000000000..450c7d7b1 --- /dev/null +++ b/src/App/src/store/store.ts @@ -0,0 +1,19 @@ +/** + * Redux store configuration + */ + +import { configureStore } from '@reduxjs/toolkit'; +import appReducer from './appSlice'; +import chatReducer from './chatSlice'; +import contentReducer from './contentSlice'; + +export const store = configureStore({ + reducer: { + app: appReducer, + chat: chatReducer, + content: contentReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/app/frontend/src/styles/global.css b/src/App/src/styles/global.css similarity index 83% rename from src/app/frontend/src/styles/global.css rename to src/App/src/styles/global.css index 6780b41e8..2b24512f0 100644 --- a/src/app/frontend/src/styles/global.css +++ b/src/App/src/styles/global.css @@ -195,66 +195,6 @@ html, body, #root { Input Wrapper - Coral Style ============================================ */ -.input-wrapper { - margin: 0 16px 16px 16px; - display: inline-flex; - flex-direction: column; - gap: 8px; - align-items: stretch; - width: calc(100% - 32px); - padding: 8px; - border-radius: var(--borderRadiusLarge, 8px); - background-color: var(--colorNeutralBackground1, #ffffff); - border: 1px solid var(--colorNeutralStroke1, #d1d1d1); - transition: border-color 0.2s ease-in-out; - position: relative; - box-sizing: border-box; -} - -.input-wrapper:hover { - border-color: var(--colorNeutralStroke1Hover, #c7c7c7); -} - -.input-wrapper:active, -.input-wrapper.focused { - border-color: var(--colorNeutralStroke1Pressed, #b3b3b3); -} - -/* Input Field */ -.input-field { - resize: none; - overflow-y: scroll; - height: auto; - min-height: 24px; - max-height: 150px; - padding: 8px; - background-color: transparent; - border: none; - outline: none; - font-family: var(--fontFamilyBase); - font-size: var(--fontSizeBase300, 14px); - font-weight: var(--fontWeightRegular, 400); - color: var(--colorNeutralForeground1, #242424); - line-height: 1.5; - box-sizing: border-box; -} - -/* Focus Indicator Line */ -.focus-indicator { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 2px; - background-color: var(--colorCompoundBrandStroke, #0078d4); - transform: scaleX(0); - transition: transform 0.2s ease-in-out; -} - -.input-wrapper.focused .focus-indicator { - transform: scaleX(1); -} - /* ============================================ Assistant Message Footer ============================================ */ @@ -316,22 +256,6 @@ html, body, #root { animation-delay: 0.4s; } -/* ============================================ - Scroll Button - ============================================ */ - -.scroll-to-bottom { - position: absolute; - left: 50%; - transform: translateX(-50%); - z-index: 1000; - border-radius: 50%; - padding: 8px; - cursor: pointer; - box-shadow: var(--shadow8); - margin-bottom: 16px; -} - /* ============================================ Animations ============================================ */ @@ -429,30 +353,6 @@ pre code.language-bash { background-color: transparent; } -/* ============================================ - Loading Spinner - ============================================ */ - -.loading-spinner { - width: 16px; - height: 16px; - border: 2px solid var(--colorNeutralStroke3, #f3f3f3); - border-top: 2px solid var(--colorBrandForeground1, #0078d4); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -.loading-conversation { - color: var(--colorNeutralForeground2); -} - -.loading-conversation .spinner { - border: 2px solid var(--colorNeutralStroke3); - border-top: 2px solid var(--colorBrandForeground1); - border-radius: 50%; - animation: spin 1s linear infinite; -} - /* ============================================ Compliance Severity Colors ============================================ */ diff --git a/src/app/frontend/src/styles/images/SamplePrompt.png b/src/App/src/styles/images/SamplePrompt.png similarity index 100% rename from src/app/frontend/src/styles/images/SamplePrompt.png rename to src/App/src/styles/images/SamplePrompt.png diff --git a/src/app/frontend/src/styles/images/contoso.svg b/src/App/src/styles/images/contoso.svg similarity index 100% rename from src/app/frontend/src/styles/images/contoso.svg rename to src/App/src/styles/images/contoso.svg diff --git a/src/app/frontend/src/styles/images/firstprompt.png b/src/App/src/styles/images/firstprompt.png similarity index 100% rename from src/app/frontend/src/styles/images/firstprompt.png rename to src/App/src/styles/images/firstprompt.png diff --git a/src/app/frontend/src/styles/images/secondprompt.png b/src/App/src/styles/images/secondprompt.png similarity index 100% rename from src/app/frontend/src/styles/images/secondprompt.png rename to src/App/src/styles/images/secondprompt.png diff --git a/src/app/frontend/src/types/index.ts b/src/App/src/types/index.ts similarity index 78% rename from src/app/frontend/src/types/index.ts rename to src/App/src/types/index.ts index 33de59481..cc6747862 100644 --- a/src/app/frontend/src/types/index.ts +++ b/src/App/src/types/index.ts @@ -47,14 +47,6 @@ export interface ChatMessage { violations?: ComplianceViolation[]; } -export interface Conversation { - id: string; - user_id: string; - messages: ChatMessage[]; - brief?: CreativeBrief; - updated_at: string; -} - export interface AgentResponse { type: 'agent_response' | 'error' | 'status' | 'heartbeat'; agent?: string; @@ -72,29 +64,6 @@ export interface AgentResponse { }; } -export interface BrandGuidelines { - tone: string; - voice: string; - primary_color: string; - secondary_color: string; - prohibited_words: string[]; - required_disclosures: string[]; - max_headline_length: number; - max_body_length: number; - require_cta: boolean; -} - -export interface ParsedBriefResponse { - brief?: CreativeBrief; - requires_confirmation: boolean; - requires_clarification?: boolean; - clarifying_questions?: string; - rai_blocked?: boolean; - message: string; - conversation_id?: string; - generated_title?: string; -} - export interface GeneratedContent { text_content?: { headline?: string; diff --git a/src/App/src/utils/constants.ts b/src/App/src/utils/constants.ts new file mode 100644 index 000000000..dbd25aae4 --- /dev/null +++ b/src/App/src/utils/constants.ts @@ -0,0 +1,42 @@ +/** + * Shared constants used across components + */ + +import type { CreativeBrief } from '../types'; + +/** Canonical field display order and labels for CreativeBrief */ +export const BRIEF_FIELD_CONFIG: { key: keyof CreativeBrief; label: string }[] = [ + { key: 'overview', label: 'Overview' }, + { key: 'objectives', label: 'Objectives' }, + { key: 'target_audience', label: 'Target Audience' }, + { key: 'key_message', label: 'Key Message' }, + { key: 'tone_and_style', label: 'Tone & Style' }, + { key: 'visual_guidelines', label: 'Visual Guidelines' }, + { key: 'deliverable', label: 'Deliverable' }, + { key: 'timelines', label: 'Timelines' }, + { key: 'cta', label: 'Call to Action' }, +]; + +/** All brief field keys in display order */ +export const BRIEF_FIELD_KEYS: (keyof CreativeBrief)[] = BRIEF_FIELD_CONFIG.map(f => f.key); + +/** Map from field key to label */ +export const BRIEF_FIELD_LABELS: Record = Object.fromEntries( + BRIEF_FIELD_CONFIG.map(f => [f.key, f.label]) +) as Record; + +/** Default product fallback values */ +export const PRODUCT_DEFAULTS = { + fallbackTags: 'soft white, airy, minimal, fresh', + fallbackPrice: 59.95, + fallbackAltText: 'Generated marketing image', +} as const; + +/** Polling configuration for content generation */ +export const POLLING_CONFIG = { + maxAttempts: 600, + intervalMs: 1000, +} as const; + +/** Standard AI disclaimer shown in multiple components */ +export const AI_DISCLAIMER = 'AI-generated content may be incorrect'; diff --git a/src/App/src/utils/contentParser.ts b/src/App/src/utils/contentParser.ts new file mode 100644 index 000000000..fe35b508b --- /dev/null +++ b/src/App/src/utils/contentParser.ts @@ -0,0 +1,133 @@ +/** + * Content parsing utilities for processing API responses + */ + +import type { GeneratedContent } from '../types'; +import { PRODUCT_DEFAULTS } from './constants'; + +/** + * Convert Azure blob storage URLs to proxy API URLs + */ +export function convertBlobUrl(imageUrl: string): string { + if (imageUrl && imageUrl.includes('blob.core.windows.net')) { + const parts = imageUrl.split('/'); + const filename = parts[parts.length - 1]; + const convId = parts[parts.length - 2]; + return `/api/images/${convId}/${filename}`; + } + return imageUrl; +} + +/** + * Parse text_content which may be a JSON string or object + */ +export function parseTextContent(raw: unknown): Record | undefined { + if (typeof raw === 'string') { + try { + return JSON.parse(raw); + } catch { + return undefined; + } + } + return raw as Record | undefined; +} + +/** + * Parse event content string to result object + */ +export function parseEventContent(content: string | unknown): Record { + if (typeof content === 'string') { + try { + return JSON.parse(content); + } catch { + return {}; + } + } + return (content as Record) || {}; +} + +/** + * Parse the final agent response into a GeneratedContent object + */ +export function parseGeneratedContent(result: Record): GeneratedContent { + let imageUrl = result?.image_url as string | undefined; + if (imageUrl) { + imageUrl = convertBlobUrl(imageUrl); + } + + const textContent = parseTextContent(result?.text_content); + + return { + text_content: textContent ? { + headline: textContent.headline as string | undefined, + body: textContent.body as string | undefined, + cta_text: textContent.cta as string | undefined, + } : { + headline: result?.headline as string | undefined, + body: result?.body as string | undefined, + cta_text: result?.cta as string | undefined, + }, + image_content: imageUrl ? { + image_url: imageUrl, + prompt_used: result?.image_prompt as string | undefined, + alt_text: (result?.image_revised_prompt as string) || PRODUCT_DEFAULTS.fallbackAltText, + } : undefined, + violations: (result?.violations as unknown as GeneratedContent['violations']) || [], + requires_modification: (result?.requires_modification as boolean) || false, + }; +} + +/** + * Merge a regeneration result into existing generated content. + * Keeps existing text/image if not present in the new result. + */ +export function mergeRegenerationResult( + result: Record, + existing: GeneratedContent | null, +): GeneratedContent { + const parsed = parseGeneratedContent(result); + return { + text_content: parsed.text_content?.headline ? parsed.text_content : existing?.text_content, + image_content: parsed.image_content || existing?.image_content, + violations: existing?.violations || [], + requires_modification: existing?.requires_modification || false, + }; +} + +/** + * Restore GeneratedContent from a persisted conversation payload. + * Handles image_base64, tagline, error fields etc. that aren't in the live generation path. + */ +export function restoreGeneratedContent(gc: Record): GeneratedContent { + let textContent = gc.text_content; + if (typeof textContent === 'string') { + textContent = parseTextContent(textContent); + } + + let imageUrl = gc.image_url as string | undefined; + if (imageUrl) imageUrl = convertBlobUrl(imageUrl); + if (!imageUrl && gc.image_base64) { + imageUrl = `data:image/png;base64,${gc.image_base64}`; + } + + const tc = textContent as Record | undefined; + + return { + text_content: tc ? { + headline: tc.headline as string | undefined, + body: tc.body as string | undefined, + cta_text: tc.cta as string | undefined, + tagline: tc.tagline as string | undefined, + } : undefined, + image_content: (imageUrl || gc.image_prompt) ? { + image_url: imageUrl, + prompt_used: gc.image_prompt as string | undefined, + alt_text: (gc.image_revised_prompt as string) || PRODUCT_DEFAULTS.fallbackAltText, + } : undefined, + violations: (gc.violations as GeneratedContent['violations']) || [], + requires_modification: (gc.requires_modification as boolean) || false, + error: gc.error as string | undefined, + image_error: gc.image_error as string | undefined, + text_error: gc.text_error as string | undefined, + }; +} diff --git a/src/App/src/utils/errorUtils.ts b/src/App/src/utils/errorUtils.ts new file mode 100644 index 000000000..df683f22c --- /dev/null +++ b/src/App/src/utils/errorUtils.ts @@ -0,0 +1,34 @@ +/** Content filter detection patterns */ +const FILTER_PATTERNS = [ + 'content_filter', 'contentfilter', 'content management policy', + 'responsibleai', 'responsible_ai_policy', 'content filtering', + 'filtered', 'safety system', 'self_harm', 'sexual', 'violence', 'hate', +]; + +/** Check if an error message indicates a content safety filter was triggered */ +export function isContentFilterError(errorMessage?: string): boolean { + if (!errorMessage) return false; + const lower = errorMessage.toLowerCase(); + return FILTER_PATTERNS.some(pattern => lower.includes(pattern)); +} + +/** Get a user-friendly title + description for an error */ +export function getErrorMessage(errorMessage?: string): { title: string; description: string } { + if (isContentFilterError(errorMessage)) { + return { + title: 'Content Filtered', + description: 'Your request was blocked by content safety filters. Please try modifying your creative brief.', + }; + } + return { + title: 'Generation Failed', + description: errorMessage || 'An error occurred. Please try again.', + }; +} + +/** Copy text to the clipboard, silently swallowing errors */ +export function copyToClipboard(text: string): Promise { + return navigator.clipboard.writeText(text).catch(() => { + // Clipboard write failed — no action needed + }); +} diff --git a/src/App/src/utils/httpClient.ts b/src/App/src/utils/httpClient.ts new file mode 100644 index 000000000..068146fc1 --- /dev/null +++ b/src/App/src/utils/httpClient.ts @@ -0,0 +1,75 @@ +/** + * HTTP Client utility for making API requests + * Centralizes fetch logic with consistent error handling and request interceptors + */ + +const API_BASE = '/api'; + +type RequestInterceptor = (config: RequestInit) => RequestInit; + +class HttpClient { + private baseUrl: string; + private requestInterceptors: RequestInterceptor[] = []; + + constructor(baseUrl: string = API_BASE) { + this.baseUrl = baseUrl; + } + + /** Register a request interceptor to modify every outgoing request */ + addRequestInterceptor(interceptor: RequestInterceptor): void { + this.requestInterceptors.push(interceptor); + } + + private applyInterceptors(config: RequestInit): RequestInit { + return this.requestInterceptors.reduce((cfg, fn) => fn(cfg), config); + } + + private async request(method: string, path: string, options: RequestInit = {}): Promise { + const config = this.applyInterceptors({ ...options, method }); + const response = await fetch(`${this.baseUrl}${path}`, config); + if (!response.ok) { + throw new Error(`${method} ${path} failed: ${response.statusText}`); + } + return response.json(); + } + + async get(path: string, signal?: AbortSignal): Promise { + return this.request('GET', path, { signal }); + } + + async post(path: string, body: unknown, signal?: AbortSignal): Promise { + return this.request('POST', path, { + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + async delete(path: string, signal?: AbortSignal): Promise { + const config = this.applyInterceptors({ method: 'DELETE', signal }); + const response = await fetch(`${this.baseUrl}${path}`, config); + if (!response.ok) { + throw new Error(`DELETE ${path} failed: ${response.statusText}`); + } + } + + async put(path: string, body: unknown, signal?: AbortSignal): Promise { + return this.request('PUT', path, { + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + /** Fetch without base URL prefix, for non-API endpoints like /.auth/me */ + async fetchExternal(url: string, options?: RequestInit): Promise { + const config = this.applyInterceptors(options || {}); + const response = await fetch(url, config); + if (!response.ok) { + throw new Error(`${config.method || 'GET'} ${url} failed: ${response.statusText}`); + } + return response.json(); + } +} + +export const httpClient = new HttpClient(); diff --git a/src/App/src/utils/imageDownload.ts b/src/App/src/utils/imageDownload.ts new file mode 100644 index 000000000..8ef35c544 --- /dev/null +++ b/src/App/src/utils/imageDownload.ts @@ -0,0 +1,74 @@ +/** + * Download an image with a branded banner overlay. + * Falls back to a plain download if canvas rendering fails. + */ +export function downloadImageWithBanner( + imageUrl: string, + headline: string, + tagline?: string, +): void { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + triggerDownload(imageUrl); + return; + } + + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + const bannerHeight = Math.max(60, img.height * 0.1); + const padding = Math.max(16, img.width * 0.03); + + canvas.width = img.width; + canvas.height = img.height + bannerHeight; + + // Draw image + ctx.drawImage(img, 0, 0); + + // Draw white banner + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, img.height, img.width, bannerHeight); + + // Banner border + ctx.strokeStyle = '#e5e5e5'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, img.height); + ctx.lineTo(img.width, img.height); + ctx.stroke(); + + // Headline + const headlineFontSize = Math.max(18, Math.min(36, img.width * 0.04)); + ctx.font = `600 ${headlineFontSize}px Georgia, serif`; + ctx.fillStyle = '#1a1a1a'; + ctx.fillText(headline, padding, img.height + padding + headlineFontSize * 0.8, img.width - padding * 2); + + // Tagline + if (tagline) { + const taglineFontSize = Math.max(12, Math.min(20, img.width * 0.025)); + ctx.font = `400 italic ${taglineFontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`; + ctx.fillStyle = '#666666'; + ctx.fillText(tagline, padding, img.height + padding + headlineFontSize + taglineFontSize * 0.8 + 4, img.width - padding * 2); + } + + canvas.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob); + triggerDownload(url, 'generated-marketing-image.png'); + URL.revokeObjectURL(url); + } + }, 'image/png'); + }; + + img.onerror = () => triggerDownload(imageUrl); + img.src = imageUrl; +} + +function triggerDownload(url: string, filename = 'generated-image.png'): void { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); +} diff --git a/src/App/src/utils/messageUtils.ts b/src/App/src/utils/messageUtils.ts new file mode 100644 index 000000000..ed5b3093a --- /dev/null +++ b/src/App/src/utils/messageUtils.ts @@ -0,0 +1,32 @@ +/** + * Message utility functions for creating ChatMessage objects + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { ChatMessage } from '../types'; + +export function createMessage( + role: 'user' | 'assistant', + content: string, + agent?: string +): ChatMessage { + return { + id: uuidv4(), + role, + content, + timestamp: new Date().toISOString(), + ...(agent && { agent }), + }; +} + +export function createErrorMessage( + content: string = 'Sorry, there was an error processing your request. Please try again.' +): ChatMessage { + return createMessage('assistant', content); +} + +export function createCancelMessage( + content: string = 'Generation stopped.' +): ChatMessage { + return createMessage('assistant', content); +} diff --git a/src/app/frontend/src/vite-env.d.ts b/src/App/src/vite-env.d.ts similarity index 100% rename from src/app/frontend/src/vite-env.d.ts rename to src/App/src/vite-env.d.ts diff --git a/src/app/frontend/tsconfig.json b/src/App/tsconfig.json similarity index 100% rename from src/app/frontend/tsconfig.json rename to src/App/tsconfig.json diff --git a/src/app/frontend/tsconfig.node.json b/src/App/tsconfig.node.json similarity index 100% rename from src/app/frontend/tsconfig.node.json rename to src/App/tsconfig.node.json diff --git a/src/app/frontend/vite.config.ts b/src/App/vite.config.ts similarity index 100% rename from src/app/frontend/vite.config.ts rename to src/App/vite.config.ts diff --git a/src/app/frontend/src/App.tsx b/src/app/frontend/src/App.tsx deleted file mode 100644 index bc6beff8c..000000000 --- a/src/app/frontend/src/App.tsx +++ /dev/null @@ -1,766 +0,0 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; -import { - Text, - Avatar, - Button, - Tooltip, - tokens, -} from '@fluentui/react-components'; -import { - History24Regular, - History24Filled, -} from '@fluentui/react-icons'; -import { v4 as uuidv4 } from 'uuid'; - -import { ChatPanel } from './components/ChatPanel'; -import { ChatHistory } from './components/ChatHistory'; -import type { ChatMessage, CreativeBrief, Product, GeneratedContent } from './types'; -import ContosoLogo from './styles/images/contoso.svg'; - - -function App() { - const [conversationId, setConversationId] = useState(() => uuidv4()); - const [conversationTitle, setConversationTitle] = useState(null); - const [userId, setUserId] = useState(''); - const [userName, setUserName] = useState(''); - const [messages, setMessages] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [generationStatus, setGenerationStatus] = useState(''); - - // Feature flags from config - const [imageGenerationEnabled, setImageGenerationEnabled] = useState(true); - - // Brief confirmation flow - const [pendingBrief, setPendingBrief] = useState(null); - const [confirmedBrief, setConfirmedBrief] = useState(null); - - // Product selection - const [selectedProducts, setSelectedProducts] = useState([]); - const [availableProducts, setAvailableProducts] = useState([]); - - // Generated content - const [generatedContent, setGeneratedContent] = useState(null); - - // Trigger for refreshing chat history - const [historyRefreshTrigger, setHistoryRefreshTrigger] = useState(0); - - // Toggle for showing/hiding chat history panel - const [showChatHistory, setShowChatHistory] = useState(true); - - // Abort controller for cancelling ongoing requests - const abortControllerRef = useRef(null); - - // Fetch app config on mount - useEffect(() => { - const fetchConfig = async () => { - try { - const { getAppConfig } = await import('./api'); - const config = await getAppConfig(); - setImageGenerationEnabled(config.enable_image_generation); - } catch (err) { - console.error('Error fetching config:', err); - // Default to enabled if config fetch fails - setImageGenerationEnabled(true); - } - }; - fetchConfig(); - }, []); - - // Fetch current user on mount - using /.auth/me (Azure App Service built-in auth endpoint) - useEffect(() => { - const fetchUser = async () => { - try { - const response = await fetch('/.auth/me'); - if (response.ok) { - const payload = await response.json(); - - // Extract user ID from objectidentifier claim - const userClaims = payload[0]?.user_claims || []; - const objectIdClaim = userClaims.find( - (claim: { typ: string; val: string }) => - claim.typ === 'http://schemas.microsoft.com/identity/claims/objectidentifier' - ); - setUserId(objectIdClaim?.val || 'anonymous'); - - // Extract display name from 'name' claim - const nameClaim = userClaims.find( - (claim: { typ: string; val: string }) => claim.typ === 'name' - ); - setUserName(nameClaim?.val || ''); - } - } catch (err) { - console.error('Error fetching user:', err); - setUserId('anonymous'); - setUserName(''); - } - }; - fetchUser(); - }, []); - - // Handle selecting a conversation from history - const handleSelectConversation = useCallback(async (selectedConversationId: string) => { - try { - const response = await fetch(`/api/conversations/${selectedConversationId}?user_id=${encodeURIComponent(userId)}`); - if (response.ok) { - const data = await response.json(); - setConversationId(selectedConversationId); - setConversationTitle(null); // Will use title from conversation list - const loadedMessages: ChatMessage[] = (data.messages || []).map((msg: { role: string; content: string; timestamp?: string; agent?: string }, index: number) => ({ - id: `${selectedConversationId}-${index}`, - role: msg.role as 'user' | 'assistant', - content: msg.content, - timestamp: msg.timestamp || new Date().toISOString(), - agent: msg.agent, - })); - setMessages(loadedMessages); - - // Only set confirmedBrief if the brief was actually confirmed - // Check metadata.brief_confirmed flag or if content was generated (implying confirmation) - const briefWasConfirmed = data.metadata?.brief_confirmed || data.generated_content; - if (briefWasConfirmed && data.brief) { - setConfirmedBrief(data.brief); - setPendingBrief(null); - } else if (data.brief) { - // Brief exists but wasn't confirmed - show it as pending for confirmation - setPendingBrief(data.brief); - setConfirmedBrief(null); - } else { - setPendingBrief(null); - setConfirmedBrief(null); - } - - // Restore availableProducts so product/color name detection works - // when regenerating images in a restored conversation - if (data.brief && availableProducts.length === 0) { - try { - const productsResponse = await fetch('/api/products'); - if (productsResponse.ok) { - const productsData = await productsResponse.json(); - setAvailableProducts(productsData.products || []); - } - } catch (err) { - console.error('Error loading products for restored conversation:', err); - } - } - - if (data.generated_content) { - const gc = data.generated_content; - let textContent = gc.text_content; - if (typeof textContent === 'string') { - try { - textContent = JSON.parse(textContent); - } catch { - } - } - - let imageUrl: string | undefined = gc.image_url; - if (imageUrl && imageUrl.includes('blob.core.windows.net')) { - const parts = imageUrl.split('/'); - const filename = parts[parts.length - 1]; - const convId = parts[parts.length - 2]; - imageUrl = `/api/images/${convId}/${filename}`; - } - if (!imageUrl && gc.image_base64) { - imageUrl = `data:image/png;base64,${gc.image_base64}`; - } - - const restoredContent: GeneratedContent = { - text_content: typeof textContent === 'object' && textContent ? { - headline: textContent?.headline, - body: textContent?.body, - cta_text: textContent?.cta, - tagline: textContent?.tagline, - } : undefined, - image_content: (imageUrl || gc.image_prompt) ? { - image_url: imageUrl, - prompt_used: gc.image_prompt, - alt_text: gc.image_revised_prompt || 'Generated marketing image', - } : undefined, - violations: gc.violations || [], - requires_modification: gc.requires_modification || false, - error: gc.error, - image_error: gc.image_error, - text_error: gc.text_error, - }; - setGeneratedContent(restoredContent); - - if (gc.selected_products && Array.isArray(gc.selected_products)) { - setSelectedProducts(gc.selected_products); - } else { - setSelectedProducts([]); - } - } else { - setGeneratedContent(null); - setSelectedProducts([]); - } - } - } catch (error) { - console.error('Error loading conversation:', error); - } - }, [userId, availableProducts.length]); - - // Handle starting a new conversation - const handleNewConversation = useCallback(() => { - setConversationId(uuidv4()); - setConversationTitle(null); - setMessages([]); - setPendingBrief(null); - setConfirmedBrief(null); - setGeneratedContent(null); - setSelectedProducts([]); - }, []); - - const handleSendMessage = useCallback(async (content: string) => { - const userMessage: ChatMessage = { - id: uuidv4(), - role: 'user', - content, - timestamp: new Date().toISOString(), - }; - - setMessages(prev => [...prev, userMessage]); - setIsLoading(true); - setGenerationStatus('Processing your request...'); - - // Create new abort controller for this request - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - const { sendMessage } = await import('./api'); - - let productsToSend = selectedProducts; - if (generatedContent && confirmedBrief && availableProducts.length > 0) { - const contentLower = content.toLowerCase(); - const mentionedProduct = availableProducts.find(p => - contentLower.includes(p.product_name.toLowerCase()) - ); - if (mentionedProduct && mentionedProduct.product_name !== selectedProducts[0]?.product_name) { - productsToSend = [mentionedProduct]; - } - } - - // Send message - include brief if confirmed, products if we have them - const response = await sendMessage({ - conversation_id: conversationId, - user_id: userId, - message: content, - ...(confirmedBrief && { brief: confirmedBrief }), - ...(productsToSend.length > 0 && { selected_products: productsToSend }), - ...(generatedContent && { has_generated_content: true }), - }, signal); - - // Handle response based on action_type - switch (response.action_type) { - case 'brief_parsed': { - const brief = response.data?.brief as CreativeBrief | undefined; - const title = response.data?.generated_title as string | undefined; - if (brief) { - setPendingBrief(brief); - } - if (title && !conversationTitle) { - setConversationTitle(title); - } - break; - } - - case 'clarification_needed': { - const brief = response.data?.brief as CreativeBrief | undefined; - if (brief) { - setPendingBrief(brief); - } - break; - } - - case 'brief_confirmed': { - const brief = response.data?.brief as CreativeBrief | undefined; - const products = response.data?.products as Product[] | undefined; - if (brief) { - setConfirmedBrief(brief); - setPendingBrief(null); - } - if (products) { - setAvailableProducts(products); - } - break; - } - - case 'products_selected': { - const products = response.data?.products as Product[] | undefined; - if (products) { - setSelectedProducts(products); - } - break; - } - - case 'content_generated': { - const generatedContent = response.data?.generated_content as GeneratedContent | undefined; - if (generatedContent) { - setGeneratedContent(generatedContent); - } - break; - } - - case 'image_regenerated': { - const generatedContent = response.data?.generated_content as GeneratedContent | undefined; - if (generatedContent) { - setGeneratedContent(generatedContent); - } - break; - } - - case 'regeneration_started': { - // Poll for completion using task_id from response - // Backend already started the regeneration task via /api/chat - const { pollTaskStatus } = await import('./api'); - const taskId = response.data?.task_id as string; - - if (!taskId) { - throw new Error('No task_id received for regeneration'); - } - - setGenerationStatus('Regenerating image...'); - - for await (const event of pollTaskStatus(taskId, signal)) { - - if (event.type === 'heartbeat') { - const statusMessage = (event.content as string) || 'Regenerating image...'; - const elapsed = (event as { elapsed?: number }).elapsed || 0; - setGenerationStatus(elapsed > 0 ? `${statusMessage} (${elapsed}s)` : statusMessage); - } else if (event.type === 'agent_response' && event.is_final) { - let result: Record | undefined = event.content as unknown as Record; - if (typeof event.content === 'string') { - try { result = JSON.parse(event.content); } catch { result = {}; } - } - - let imageUrl = result?.image_url as string | undefined; - if (imageUrl && imageUrl.includes('blob.core.windows.net')) { - const parts = imageUrl.split('/'); - const filename = parts[parts.length - 1]; - const convId = parts[parts.length - 2]; - imageUrl = `/api/images/${convId}/${filename}`; - } - - // Parse text_content if it's a JSON string - let textContent = result?.text_content; - if (typeof textContent === 'string') { - try { textContent = JSON.parse(textContent); } catch { /* keep as-is */ } - } - - // Update selected products if backend provided new ones (product change) - const newProducts = result?.selected_products as Product[] | undefined; - if (newProducts && newProducts.length > 0) { - setSelectedProducts(newProducts); - } - - // Update confirmed brief if backend provided an updated one (with accumulated modifications) - const updatedBrief = result?.updated_brief as CreativeBrief | undefined; - if (updatedBrief) { - setConfirmedBrief(updatedBrief); - } - - setGeneratedContent(prev => ({ - ...prev, - // Update text content if provided (with new product name) - text_content: textContent ? { - headline: (textContent as Record).headline as string | undefined, - body: (textContent as Record).body as string | undefined, - cta_text: (textContent as Record).cta as string | undefined, - } : prev?.text_content, - image_content: imageUrl ? { - image_url: imageUrl, - prompt_used: result?.image_prompt as string | undefined, - alt_text: (result?.image_revised_prompt as string) || 'Regenerated marketing image', - } : prev?.image_content, - violations: prev?.violations || [], - requires_modification: prev?.requires_modification || false, - })); - } else if (event.type === 'error') { - throw new Error(event.content || 'Regeneration failed'); - } - } - - setGenerationStatus(''); - break; - } - - case 'start_over': { - setPendingBrief(null); - setConfirmedBrief(null); - setSelectedProducts([]); - setGeneratedContent(null); - break; - } - - case 'rai_blocked': - case 'error': - case 'chat_response': - default: - // Just show the message - break; - } - - // Add assistant message from response - if (response.message) { - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: response.message, - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } - - } catch (error) { - // Check if this was a user-initiated cancellation - if (error instanceof Error && error.name === 'AbortError') { - console.log('Request cancelled by user'); - const cancelMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Generation stopped.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, cancelMessage]); - } else { - console.error('Error sending message:', error); - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Sorry, there was an error processing your request. Please try again.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, errorMessage]); - } - } finally { - setIsLoading(false); - setGenerationStatus(''); - abortControllerRef.current = null; - // Trigger refresh of chat history after message is sent - setHistoryRefreshTrigger(prev => prev + 1); - } - }, [conversationId, userId, conversationTitle, confirmedBrief, selectedProducts, generatedContent]); - - const handleBriefConfirm = useCallback(async () => { - if (!pendingBrief) return; - - try { - const { sendMessage } = await import('./api'); - - const response = await sendMessage({ - conversation_id: conversationId, - user_id: userId, - action: 'confirm_brief', - brief: pendingBrief, - }); - - // Update state based on response - if (response.action_type === 'brief_confirmed') { - const brief = response.data?.brief as CreativeBrief | undefined; - if (brief) { - setConfirmedBrief(brief); - } else { - setConfirmedBrief(pendingBrief); - } - setPendingBrief(null); - - // Fetch products separately after confirmation - try { - const productsResponse = await fetch('/api/products'); - if (productsResponse.ok) { - const productsData = await productsResponse.json(); - setAvailableProducts(productsData.products || []); - } - } catch (err) { - console.error('Error loading products after confirmation:', err); - } - } - - // Add assistant message - if (response.message) { - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: response.message, - agent: 'ProductAgent', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } - } catch (error) { - console.error('Error confirming brief:', error); - } - }, [conversationId, userId, pendingBrief]); - - const handleBriefCancel = useCallback(async () => { - setPendingBrief(null); - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'No problem. Please provide your creative brief again or ask me any questions.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - }, []); - - const handleProductsStartOver = useCallback(async () => { - try { - const { sendMessage } = await import('./api'); - - const response = await sendMessage({ - conversation_id: conversationId, - user_id: userId, - action: 'start_over', - }); - - console.log('Start over response:', response); - - // Reset all local state - setPendingBrief(null); - setConfirmedBrief(null); - setSelectedProducts([]); - setGeneratedContent(null); - - // Add assistant message - if (response.message) { - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: response.message, - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } - } catch (error) { - console.error('Error starting over:', error); - // Still reset local state even if backend call fails - setPendingBrief(null); - setConfirmedBrief(null); - setSelectedProducts([]); - setGeneratedContent(null); - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Starting over. Please provide your creative brief to begin a new campaign.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } - }, [conversationId, userId]); - - const handleProductSelect = useCallback((product: Product) => { - const isSelected = selectedProducts.some( - p => (p.sku || p.product_name) === (product.sku || product.product_name) - ); - - if (isSelected) { - // Deselect - but user must have at least one selected to proceed - setSelectedProducts([]); - } else { - // Single selection mode - replace any existing selection - setSelectedProducts([product]); - } - }, [selectedProducts]); - - const handleStopGeneration = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }, []); - - const handleGenerateContent = useCallback(async () => { - if (!confirmedBrief) return; - - setIsLoading(true); - setGenerationStatus('Starting content generation...'); - - // Create new abort controller for this request - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - const { streamGenerateContent } = await import('./api'); - - for await (const event of streamGenerateContent({ - conversation_id: conversationId, - user_id: userId, - brief: confirmedBrief as unknown as Record, - products: selectedProducts as unknown as Array>, - generate_images: imageGenerationEnabled, - }, signal)) { - - if (event.type === 'heartbeat') { - const statusMessage = (event.content as string) || 'Generating content...'; - const elapsed = (event as { elapsed?: number }).elapsed || 0; - setGenerationStatus(elapsed > 0 ? `${statusMessage} (${elapsed}s)` : statusMessage); - } else if (event.type === 'agent_response' && event.is_final) { - let result: Record | undefined = event.content as unknown as Record; - if (typeof event.content === 'string') { - try { - result = JSON.parse(event.content); - } catch { - result = {}; - } - } - - let imageUrl = result?.image_url as string | undefined; - - // Convert blob URLs to proxy URLs - if (imageUrl && imageUrl.includes('blob.core.windows.net')) { - const parts = imageUrl.split('/'); - const filename = parts[parts.length - 1]; - const convId = parts[parts.length - 2]; - imageUrl = `/api/images/${convId}/${filename}`; - } - - // Parse text_content - it may be a JSON string from backend - let textContent: Record | undefined; - const rawTextContent = result?.text_content; - if (typeof rawTextContent === 'string') { - try { - textContent = JSON.parse(rawTextContent); - } catch { - console.error('Failed to parse text_content JSON string'); - textContent = undefined; - } - } else { - textContent = rawTextContent as Record | undefined; - } - - const generatedContent: GeneratedContent = { - text_content: textContent ? { - headline: textContent.headline as string | undefined, - body: textContent.body as string | undefined, - cta_text: textContent.cta as string | undefined, - } : { - headline: result?.headline as string | undefined, - body: result?.body as string | undefined, - cta_text: result?.cta as string | undefined, - }, - image_content: imageUrl ? { - image_url: imageUrl, - prompt_used: result?.image_prompt as string | undefined, - alt_text: (result?.image_revised_prompt as string) || 'Generated marketing image', - } : undefined, - violations: (result?.violations as unknown as GeneratedContent['violations']) || [], - requires_modification: (result?.requires_modification as boolean) || false, - }; - - setGeneratedContent(generatedContent); - } else if (event.type === 'error') { - throw new Error(event.content || 'Generation failed'); - } - } - - setGenerationStatus(''); - } catch (error) { - // Check if this was a user-initiated cancellation - if (error instanceof Error && error.name === 'AbortError') { - console.log('Content generation cancelled by user'); - const cancelMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Content generation stopped.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, cancelMessage]); - } else { - console.error('Error generating content:', error); - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Sorry, there was an error generating content. Please try again.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, errorMessage]); - } - } finally { - setIsLoading(false); - setGenerationStatus(''); - abortControllerRef.current = null; - } - }, [confirmedBrief, selectedProducts, conversationId, userId, imageGenerationEnabled]); - - return ( -
- {/* Header */} -
-
- Contoso - - Contoso - -
-
- -
-
- - {/* Main Content */} -
- {/* Chat Panel - main area */} -
- -
- - {/* Chat History Sidebar - RIGHT side */} - {showChatHistory && ( -
- -
- )} -
-
- ); -} - -export default App; diff --git a/src/app/frontend/src/api/index.ts b/src/app/frontend/src/api/index.ts deleted file mode 100644 index 36781b77b..000000000 --- a/src/app/frontend/src/api/index.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * API service for interacting with the Content Generation backend - */ - -import type { - AgentResponse, - AppConfig, - MessageRequest, - MessageResponse, -} from '../types'; - -const API_BASE = '/api'; - -/** - * Send a message or action to the /api/chat endpoint - */ -export async function sendMessage( - request: MessageRequest, - signal?: AbortSignal -): Promise { - const response = await fetch(`${API_BASE}/chat`, { - signal, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }); - - if (!response.ok) { - throw new Error(`Message request failed: ${response.statusText}`); - } - - return response.json(); -} - -/** - * Get application configuration including feature flags - */ -export async function getAppConfig(): Promise { - const response = await fetch(`${API_BASE}/config`); - - if (!response.ok) { - throw new Error(`Failed to get config: ${response.statusText}`); - } - - return response.json(); -} - -/** - * Request for content generation - */ -export interface GenerateRequest { - conversation_id: string; - user_id: string; - brief: Record; - products: Array>; - generate_images: boolean; -} - -/** - * Generate content from a confirmed brief - */ -export async function* streamGenerateContent( - request: GenerateRequest, - signal?: AbortSignal -): AsyncGenerator { - // Use polling-based approach for reliability with long-running tasks - const startResponse = await fetch(`${API_BASE}/generate/start`, { - signal, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - brief: request.brief, - products: request.products || [], - generate_images: request.generate_images, - conversation_id: request.conversation_id, - user_id: request.user_id || 'anonymous', - }), - }); - - if (!startResponse.ok) { - throw new Error(`Content generation failed to start: ${startResponse.statusText}`); - } - - const startData = await startResponse.json(); - const taskId = startData.task_id; - - console.log(`Generation started with task ID: ${taskId}`); - - // Yield initial status - yield { - type: 'status', - content: 'Generation started...', - is_final: false, - } as AgentResponse; - - // Poll for completion - let attempts = 0; - const maxAttempts = 600; // 10 minutes max with 1-second polling (image generation can take 3-5 min) - const pollInterval = 1000; // 1 second - - while (attempts < maxAttempts) { - // Check if cancelled before waiting - if (signal?.aborted) { - throw new DOMException('Generation cancelled by user', 'AbortError'); - } - - await new Promise(resolve => setTimeout(resolve, pollInterval)); - attempts++; - - // Check if cancelled after waiting - if (signal?.aborted) { - throw new DOMException('Generation cancelled by user', 'AbortError'); - } - - try { - const statusResponse = await fetch(`${API_BASE}/generate/status/${taskId}`, { signal }); - if (!statusResponse.ok) { - throw new Error(`Failed to get task status: ${statusResponse.statusText}`); - } - - const statusData = await statusResponse.json(); - - if (statusData.status === 'completed') { - // Yield the final result - yield { - type: 'agent_response', - content: JSON.stringify(statusData.result), - is_final: true, - } as AgentResponse; - return; - } else if (statusData.status === 'failed') { - throw new Error(statusData.error || 'Generation failed'); - } else if (statusData.status === 'running') { - // Determine progress stage based on elapsed time - // Typical generation: 0-10s briefing, 10-25s copy, 25-45s image, 45-60s compliance - const elapsedSeconds = attempts; - let stage: number; - let stageMessage: string; - - if (elapsedSeconds < 10) { - stage = 0; - stageMessage = 'Analyzing creative brief...'; - } else if (elapsedSeconds < 25) { - stage = 1; - stageMessage = 'Generating marketing copy...'; - } else if (elapsedSeconds < 35) { - stage = 2; - stageMessage = 'Creating image prompt...'; - } else if (elapsedSeconds < 55) { - stage = 3; - stageMessage = 'Generating image with AI...'; - } else if (elapsedSeconds < 70) { - stage = 4; - stageMessage = 'Running compliance check...'; - } else { - stage = 5; - stageMessage = 'Finalizing content...'; - } - - // Send status update every second for smoother progress - yield { - type: 'heartbeat', - content: stageMessage, - count: stage, - elapsed: elapsedSeconds, - is_final: false, - } as AgentResponse; - } - } catch (error) { - console.error(`Error polling task ${taskId}:`, error); - // Continue polling on transient errors - if (attempts >= maxAttempts) { - throw error; - } - } - } - - throw new Error('Generation timed out after 10 minutes'); -} - -/** - * Poll for task completion using task_id - * Used for both content generation and image regeneration - */ -export async function* pollTaskStatus( - taskId: string, - signal?: AbortSignal -): AsyncGenerator { - let attempts = 0; - const maxAttempts = 600; // 10 minutes max with 1-second polling - const pollInterval = 1000; // 1 second - - while (attempts < maxAttempts) { - if (signal?.aborted) { - throw new DOMException('Operation cancelled by user', 'AbortError'); - } - - await new Promise(resolve => setTimeout(resolve, pollInterval)); - attempts++; - - if (signal?.aborted) { - throw new DOMException('Operation cancelled by user', 'AbortError'); - } - - try { - const statusResponse = await fetch(`${API_BASE}/generate/status/${taskId}`, { signal }); - if (!statusResponse.ok) { - throw new Error(`Failed to get task status: ${statusResponse.statusText}`); - } - - const statusData = await statusResponse.json(); - - if (statusData.status === 'completed') { - yield { - type: 'agent_response', - content: JSON.stringify(statusData.result), - is_final: true, - } as AgentResponse; - return; - } else if (statusData.status === 'failed') { - throw new Error(statusData.error || 'Task failed'); - } else { - // Yield heartbeat with progress - const elapsedSeconds = attempts; - let stageMessage: string; - - if (elapsedSeconds < 10) { - stageMessage = 'Starting regeneration...'; - } else if (elapsedSeconds < 30) { - stageMessage = 'Generating new image...'; - } else if (elapsedSeconds < 50) { - stageMessage = 'Processing image...'; - } else { - stageMessage = 'Finalizing...'; - } - - yield { - type: 'heartbeat', - content: stageMessage, - elapsed: elapsedSeconds, - is_final: false, - } as AgentResponse; - } - } catch (error) { - if (attempts >= maxAttempts) { - throw error; - } - } - } - - throw new Error('Task timed out after 10 minutes'); -} \ No newline at end of file diff --git a/src/app/frontend/src/components/ChatPanel.tsx b/src/app/frontend/src/components/ChatPanel.tsx deleted file mode 100644 index bf757acf9..000000000 --- a/src/app/frontend/src/components/ChatPanel.tsx +++ /dev/null @@ -1,437 +0,0 @@ -import { useState, useRef, useEffect } from 'react'; -import { - Button, - Text, - Badge, - tokens, - Tooltip, -} from '@fluentui/react-components'; -import { - Send20Regular, - Stop24Regular, - Add20Regular, - Copy20Regular, -} from '@fluentui/react-icons'; -import ReactMarkdown from 'react-markdown'; -import type { ChatMessage, CreativeBrief, Product, GeneratedContent } from '../types'; -import { BriefReview } from './BriefReview'; -import { ConfirmedBriefView } from './ConfirmedBriefView'; -import { SelectedProductView } from './SelectedProductView'; -import { ProductReview } from './ProductReview'; -import { InlineContentPreview } from './InlineContentPreview'; -import { WelcomeCard } from './WelcomeCard'; - -interface ChatPanelProps { - messages: ChatMessage[]; - onSendMessage: (message: string) => void; - isLoading: boolean; - generationStatus?: string; - onStopGeneration?: () => void; - // Inline component props - pendingBrief?: CreativeBrief | null; - confirmedBrief?: CreativeBrief | null; - generatedContent?: GeneratedContent | null; - selectedProducts?: Product[]; - availableProducts?: Product[]; - onBriefConfirm?: () => void; - onBriefCancel?: () => void; - onGenerateContent?: () => void; - onRegenerateContent?: () => void; - onProductsStartOver?: () => void; - onProductSelect?: (product: Product) => void; - // Feature flags - imageGenerationEnabled?: boolean; - // New chat - onNewConversation?: () => void; -} - -export function ChatPanel({ - messages, - onSendMessage, - isLoading, - generationStatus, - onStopGeneration, - pendingBrief, - confirmedBrief, - generatedContent, - selectedProducts = [], - availableProducts = [], - onBriefConfirm, - onBriefCancel, - onGenerateContent, - onRegenerateContent, - onProductsStartOver, - onProductSelect, - imageGenerationEnabled = true, - onNewConversation, -}: ChatPanelProps) { - const [inputValue, setInputValue] = useState(''); - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); - const inputContainerRef = useRef(null); - - // Scroll to bottom when messages change - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, pendingBrief, confirmedBrief, generatedContent, isLoading, generationStatus]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (inputValue.trim() && !isLoading) { - onSendMessage(inputValue.trim()); - setInputValue(''); - } - }; - - // Determine if we should show inline components - const showBriefReview = pendingBrief && onBriefConfirm && onBriefCancel; - const showProductReview = confirmedBrief && !generatedContent && onGenerateContent; - const showContentPreview = generatedContent && onRegenerateContent; - const showWelcome = messages.length === 0 && !showBriefReview && !showProductReview && !showContentPreview; - - // Handle suggestion click from welcome card - const handleSuggestionClick = (prompt: string) => { - setInputValue(prompt); - }; - - return ( -
- {/* Messages Area */} -
- {showWelcome ? ( - - ) : ( - <> - {messages.map((message) => ( - - ))} - - {/* Brief Review - Read Only with Conversational Prompts */} - {showBriefReview && ( - - )} - - {/* Confirmed Brief View - Persistent read-only view */} - {confirmedBrief && !pendingBrief && ( - - )} - - {/* Selected Product View - Persistent read-only view after content generation */} - {generatedContent && selectedProducts.length > 0 && ( - - )} - - {/* Product Review - Conversational Product Selection */} - {showProductReview && ( - {})} - isAwaitingResponse={isLoading} - onProductSelect={onProductSelect} - disabled={isLoading} - /> - )} - - {/* Inline Content Preview */} - {showContentPreview && ( - 0 ? selectedProducts[0] : undefined} - imageGenerationEnabled={imageGenerationEnabled} - /> - )} - - {/* Loading/Typing Indicator - Coral Style */} - {isLoading && ( -
-
- - - - - -
- - {generationStatus || 'Thinking...'} - - {onStopGeneration && ( - - - - )} -
- )} - - )} - -
-
- - {/* Input Area - Simple single-line like Figma */} -
- {/* Input Box */} -
- setInputValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); - } - }} - placeholder="Type a message" - disabled={isLoading} - style={{ - flex: 1, - border: 'none', - outline: 'none', - backgroundColor: 'transparent', - fontFamily: 'var(--fontFamilyBase)', - fontSize: '14px', - color: tokens.colorNeutralForeground1, - }} - /> - - {/* Icons on the right */} -
- -
-
- - {/* Disclaimer - Outside the input box */} - - AI generated content may be incorrect - -
-
- ); -} - -// Copy function for messages -const handleCopy = (text: string) => { - navigator.clipboard.writeText(text).catch((err) => { - console.error('Failed to copy text:', err); - }); -}; - -function MessageBubble({ message }: { message: ChatMessage }) { - const isUser = message.role === 'user'; - const [copied, setCopied] = useState(false); - - const onCopy = () => { - handleCopy(message.content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- {/* Agent badge for assistant messages */} - {!isUser && message.agent && ( - - {message.agent} - - )} - - {/* Message content with markdown */} -
- - {message.content} - - - {/* Footer for assistant messages - Coral style */} - {!isUser && ( -
- - AI-generated content may be incorrect - - -
- -
-
- )} -
-
- ); -} diff --git a/src/app/frontend/src/components/ProductReview.tsx b/src/app/frontend/src/components/ProductReview.tsx deleted file mode 100644 index 9c0d9e960..000000000 --- a/src/app/frontend/src/components/ProductReview.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { - Button, - Text, - tokens, -} from '@fluentui/react-components'; -import { - Sparkle20Regular, - Box20Regular, -} from '@fluentui/react-icons'; -import type { Product } from '../types'; - -interface ProductReviewProps { - products: Product[]; - onConfirm: () => void; - onStartOver: () => void; - isAwaitingResponse?: boolean; - availableProducts?: Product[]; - onProductSelect?: (product: Product) => void; - disabled?: boolean; -} - -export function ProductReview({ - products, - onConfirm, - onStartOver: _onStartOver, - isAwaitingResponse = false, - availableProducts = [], - onProductSelect, - disabled = false, -}: ProductReviewProps) { - const displayProducts = availableProducts.length > 0 ? availableProducts : products; - const selectedProductIds = new Set(products.map(p => p.sku || p.product_name)); - - const isProductSelected = (product: Product): boolean => { - return selectedProductIds.has(product.sku || product.product_name); - }; - - const handleProductClick = (product: Product) => { - if (onProductSelect) { - onProductSelect(product); - } - }; - - return ( -
-
- - Here is the list of available paints: - -
- - {displayProducts.length > 0 ? ( -
- {displayProducts.map((product, index) => ( - handleProductClick(product)} - disabled={disabled} - /> - ))} -
- ) : ( -
- - No products available. - -
- )} - - {displayProducts.length > 0 && ( -
- - {products.length === 0 && ( - - Select a product to continue - - )} -
- )} - -
- - AI-generated content may be incorrect - -
-
- ); -} - -interface ProductCardGridProps { - product: Product; - isSelected: boolean; - onClick: () => void; - disabled?: boolean; -} - -function ProductCardGrid({ product, isSelected, onClick, disabled = false }: ProductCardGridProps) { - return ( -
- {product.image_url ? ( - {product.product_name} - ) : ( -
- -
- )} - -
- - {product.product_name} - - - {product.tags || product.description || 'soft white, airy, minimal, fresh'} - - - ${product.price?.toFixed(2) || '59.95'} USD - -
-
- ); -} diff --git a/src/app/frontend/src/components/SelectedProductView.tsx b/src/app/frontend/src/components/SelectedProductView.tsx deleted file mode 100644 index a4c4540f6..000000000 --- a/src/app/frontend/src/components/SelectedProductView.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { - Text, - Badge, - tokens, -} from '@fluentui/react-components'; -import { - Checkmark20Regular, - Box20Regular, -} from '@fluentui/react-icons'; -import type { Product } from '../types'; - -interface SelectedProductViewProps { - products: Product[]; -} - -export function SelectedProductView({ products }: SelectedProductViewProps) { - if (products.length === 0) return null; - - return ( -
-
- } - > - Products Selected - -
- -
- {products.map((product, index) => ( -
- {product.image_url ? ( - {product.product_name} - ) : ( -
- -
- )} - -
- - {product.product_name} - - - {product.tags || product.description || 'soft white, airy, minimal, fresh'} - - - ${product.price?.toFixed(2) || '59.95'} USD - -
-
- ))} -
-
- ); -}