diff --git a/.coverage b/.coverage deleted file mode 100644 index 51fea3fcb..000000000 Binary files a/.coverage and /dev/null differ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7a32def93..a42156bfc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -35,8 +35,8 @@ updates: # npm dependencies - grouped - package-ecosystem: "npm" directories: - - "/src/App" - - "/src/App/server" + - "/src/app/frontend" + - "/src/app/frontend-server" schedule: interval: "monthly" target-branch: "dependabotchanges" diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 28a310d90..56c978126 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -8,10 +8,10 @@ on: - demo paths: - 'src/backend/**' - - 'src/App/**' - - 'src/App/server/**' - - 'src/App/WebApp.Dockerfile' - - 'src/App/.dockerignore' + - 'src/app/frontend/**' + - 'src/app/frontend-server/**' + - '.github/workflows/docker-build.yml' + pull_request: types: - opened - ready_for_review @@ -23,10 +23,8 @@ on: - demo paths: - 'src/backend/**' - - 'src/App/**' - - 'src/App/server/**' - - 'src/App/WebApp.Dockerfile' - - 'src/App/.dockerignore' + - 'src/app/frontend/**' + - 'src/app/frontend-server/**' - '.github/workflows/docker-build.yml' workflow_dispatch: @@ -90,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 cfc5b5fe2..48266fab7 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 310b95883..3696c9d2f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,15 +36,15 @@ eggs/ *.swo # Node -/src/App/node_modules/ -/src/App/server/node_modules/ -/src/App/server/static/ -/src/App/server/*.zip +/src/app/frontend/node_modules/ +/src/app/frontend-server/node_modules/ +/src/app/frontend-server/static/ +/src/app/frontend-server/*.zip node_modules/ # Build output -/src/App/static/ -/src/App/dist/ +/src/app/static/ +/src/app/frontend/dist/ # Logs *.log diff --git a/content-gen/src/app/frontend/optimization-report.html b/content-gen/src/app/frontend/optimization-report.html deleted file mode 100644 index 710c00e38..000000000 --- a/content-gen/src/app/frontend/optimization-report.html +++ /dev/null @@ -1,1244 +0,0 @@ - - - - - - Content Generation Frontend UI Refactorization KPI Report (dev vs psl-ui-refractoring) - - - -
- -
-

🚀 Content Generation Frontend UI Refactorization KPI Report

-

Complete Technical Analysis & Implementation Details

-

Generated: July 2025 | Project: Content Generation Solution Accelerator

- -
-
- 100% - Original UI Files Impacted -
-
- 4 - Redux Toolkit Slices Added -
-
- -48.29% - Original Components LOC Reduction -
-
- +29.87% - Net UI Delta Rate -
-
-
- - -
-

📊 Executive Summary

-

Through comparison of origin/dev...psl-ui-refractoring in content-gen/src/app/frontend/src, this report captures measured UI refactorization impact with consistent diff-based KPIs.

- - -
-
- Comparison Coverage Complete ✅ -
-
-
- - -
-

🔄 Architecture: Before vs After

- -

Before: Monolithic Component Pattern

-
-
Tightly Coupled UI Logic
- -
Large Monolith Components
- -
Mixed API + UI + State
- -
Harder Maintenance
-
- -

After: Redux Toolkit + Custom Hooks + Modular Components

-
-
Redux Store
- -
4 Typed Slices
- -
21 Granular Selectors
- -
7 Custom Hooks
- -
18 Memoized Components
-
- -

Redux Slice Architecture

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SliceFileResponsibilityKey Exports
appSlicestore/appSlice.tsGlobal app-level UI stateTheme, layout, global flags
chatSlicestore/chatSlice.tsChat state and async chat operationsMessages, streaming, SSE
contentSlicestore/contentSlice.tsContent preview and generation statePreviews, generation stages
chatHistorySlicestore/chatHistorySlice.tsConversation history and session dataHistory CRUD, persistence
-
- - -
-

📈 UI Refactoring Implementation

- -
-
- - - -
- -
-

Phase 1: Component Decomposition

-

Monolithic components (App.tsx at 846 lines, ChatHistory at 616 lines) were decomposed into focused, single-responsibility components. 10 new granular components were extracted.

-
-
// Deleted
-content-gen/src/app/frontend/src/api/index.ts (replaced by httpClient)
-
-// Major reductions
-App.tsx:                846 → 72  lines (-91.5%)
-ChatHistory.tsx:        616 → 327 lines (-46.9%)
-InlineContentPreview:   528 → 196 lines (-62.9%)
-ChatPanel.tsx:          425 → 159 lines (-62.6%)
-
-// New granular components (10 files, 1,220 lines)
-components/AppHeader.tsx
-components/ChatInput.tsx
-components/ComplianceSection.tsx
-components/ConversationItem.tsx
-components/ImagePreviewCard.tsx
-components/MessageBubble.tsx
-components/ProductCard.tsx
-components/SuggestionCard.tsx
-components/TypingIndicator.tsx
-components/ViolationCard.tsx
-
-
- - - - -
-
- - -
-

📊 KPI 1: Codebase Overview

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MetricBefore (dev)After (local)Delta
Total UI source files1347+34 files
Total source lines3,6524,743+1,091 (+29.87%)
Files added35Added
Files modified12Modified
Files deleted1Legacy API file removed
Line additions3,740+3,740
Line deletions2,453−2,453
-
- - -
-

📊 KPI 2: Component Complexity (Lines of Code)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ComponentBefore (lines)After (lines)Reduction
App.tsx84672−774 (−91.49%)
ChatHistory.tsx616327−289 (−46.92%)
InlineContentPreview.tsx528196−332 (−62.88%)
ChatPanel.tsx425159−266 (−62.59%)
api/index.ts321222−99 (−30.84%)
ProductReview.tsx217128−89 (−41.01%)
BriefReview.tsx177157−20 (−11.30%)
WelcomeCard.tsx154103−51 (−33.12%)
SelectedProductView.tsx13560−75 (−55.56%)
ConfirmedBriefView.tsx8880−8 (−9.09%)
- -

Top Reduction Progress

-
-
-

App.tsx Reduction

-
-
−91.49%
-
-
-
-

InlineContentPreview.tsx Reduction

-
-
−62.88%
-
-
-
-
-
-

ChatPanel.tsx Reduction

-
-
−62.59%
-
-
-
-

SelectedProductView.tsx Reduction

-
-
−55.56%
-
-
-
-
-
-

ChatHistory.tsx Reduction

-
-
−46.92%
-
-
-
-

ProductReview.tsx Reduction

-
-
−41.01%
-
-
-
-
- - -
-

📊 KPI 3: Architecture & Modularity

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MetricBeforeAfterDelta
Custom hooks0 files7 files (797 lines)+7 hook modules
State management files08 files (501 lines)+8 store modules
Utility modules09 files (423 lines)+9 under utils/
New components010 files (1,220 lines)+10 granular components
Typed selectors021+21 centralized in selectors.ts
- -

New Files Created

-
-
-

State Management (8 files)

-
    -
  • ✅ store/index.ts
  • -
  • ✅ store/store.ts
  • -
  • ✅ store/hooks.ts
  • -
  • ✅ store/selectors.ts
  • -
  • ✅ store/appSlice.ts
  • -
  • ✅ store/chatSlice.ts
  • -
  • ✅ store/contentSlice.ts
  • -
  • ✅ store/chatHistorySlice.ts
  • -
-
-
-

Custom Hooks (7 files)

-
    -
  • ✅ hooks/index.ts
  • -
  • ✅ hooks/useAutoScroll.ts
  • -
  • ✅ hooks/useChatOrchestrator.ts
  • -
  • ✅ hooks/useContentGeneration.ts
  • -
  • ✅ hooks/useConversationActions.ts
  • -
  • ✅ hooks/useCopyToClipboard.ts
  • -
  • ✅ hooks/useWindowSize.ts
  • -
-
-
-
-
-

Utility Modules (9 files)

-
    -
  • ✅ utils/index.ts
  • -
  • ✅ utils/briefFields.ts
  • -
  • ✅ utils/contentErrors.ts
  • -
  • ✅ utils/contentParsing.ts
  • -
  • ✅ utils/downloadImage.ts
  • -
  • ✅ utils/generationStages.ts
  • -
  • ✅ utils/messageUtils.ts
  • -
  • ✅ utils/sseParser.ts
  • -
  • ✅ utils/stringUtils.ts
  • -
-
-
-

New Components (10 files)

-
    -
  • ✅ components/AppHeader.tsx (65 lines)
  • -
  • ✅ components/ChatInput.tsx (139 lines)
  • -
  • ✅ components/ComplianceSection.tsx (187 lines)
  • -
  • ✅ components/ConversationItem.tsx (276 lines)
  • -
  • ✅ components/ImagePreviewCard.tsx (95 lines)
  • -
  • ✅ components/MessageBubble.tsx (113 lines)
  • -
  • ✅ components/ProductCard.tsx (132 lines)
  • -
  • ✅ components/SuggestionCard.tsx (81 lines)
  • -
  • ✅ components/TypingIndicator.tsx (71 lines)
  • -
  • ✅ components/ViolationCard.tsx (61 lines)
  • -
-
-
-
- - -
-

📊 KPI 4: Bundle Size (Production Build)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ChunkBeforeAfterDelta
Bundle KPINot measuredNot measuredBuild benchmark required
Comparison basisgit diff origin/dev...HEAD in content-gen/src/app/frontend/srcDiff-based report
Measured churn6,193 lines3,740 add / 2,453 del
Net UI delta+1,091 lines+29.87% vs dev UI baseline
StatusBundle size not included in this KPI setUse build artifacts for bundle KPI
- -
-

💡 Note: This report is strictly based on branch diff metrics. Bundle-size numbers require production build output from both refs and are intentionally excluded here.

-
-
- - -
-

📊 KPI 5: Code Quality Patterns

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PatternBeforeAfterDelta
memo( component wrappers018+18 memoized component wrappers
useCallback(1326+13 callback memoization
useMemo(09+9 derived value memoization
createSlice (Redux Toolkit)04+4 typed state modules
createAsyncThunk06+6 standardized async
useAppDispatch013+13 typed dispatch hooks
useAppSelector048+48 typed selector hooks
displayName018+18 DevTools identifiers
-
- - -
-

🐛 KPI 6: Structural Risk Reductions

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#Refactor OutcomeEvidence from DiffStatus
1Monolith App.tsx decomposedApp.tsx reduced from 846 to 72 lines (−91.5%); logic extracted to hooks, store, and componentsCompleted
2State flow centralizedAdded 4 Redux slices, 21 selectors, and typed hooks under store/Completed
3Reusable utility layer introducedAdded 9 utility modules under utils/ (SSE parsing, content parsing, string utils)Completed
4Chat rendering decomposedChatHistory (616→327), ChatPanel (425→159), extracted MessageBubble, ConversationItem, ChatInputCompleted
5Content preview modularizedInlineContentPreview (528→196), extracted ComplianceSection, ViolationCard, ImagePreviewCardCompleted
6API layer refactoredapi/index.ts reduced (321→222), SSE/HTTP client logic extracted to utils/sseParser.tsCompleted
7Product flow decomposedProductReview (217→128), extracted ProductCard, SuggestionCard sub-componentsCompleted
8Custom hooks extracted7 domain hooks created: useChatOrchestrator, useContentGeneration, useConversationActions, etc.Completed
9Performance patterns applied18 memo() wrappers, 18 displayName identifiers, 26 useCallback, 9 useMemoCompleted
10Type safety enforced0 TypeScript errors; typed Redux hooks (useAppSelector: 48 usages, useAppDispatch: 13 usages)Completed
-
- - -
-

⚡ KPI 7: Refactorization and Quality Outcomes

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OptimizationBeforeAfter
Changed files in TypeScriptN/A48/48 (.ts/.tsx)
Memoized components0 memo( patterns18 memo( patterns
Callback stability13 useCallback(26 useCallback(
Derived memoization0 useMemo(9 useMemo(
Typed Redux hooks0 usages61 usages (13 dispatch + 48 selector)
DevTools traceability0 displayName18 displayName identifiers
Async state managementManual fetch + state6 createAsyncThunk actions
- -

Refactorization Delta Snapshot

-
-
git diff --shortstat origin/dev...HEAD -- content-gen/src/app/frontend/src/
-48 files changed, 3740 insertions(+), 2453 deletions(-)
-
-Change mix:
-A=35 (72.92%)
-M=12 (25.00%)
-D=1  (2.08%)
-
-
- - -
-

🎯 Summary Scorecard

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KPIBeforeAfterVerdict
UI files impacted13 files48 changed100% of original files
State managementNo Redux slicesRedux Toolkit (4 slices)Centralized
Typed selectors021New capability
Render optimization0 memo( patterns18 memo( patternsProduction-grade
Net UI delta rate+29.87%Growth from modularization
Original components LOC2,3401,210-48.29%
TypeScript changed-file footprint48/48100%
TypeScript build errors00Clean build
- -
-
- 48 - Files Changed -
-
- 35/12/1 - Added / Modified / Deleted -
-
- +1,091 - Net UI Lines -
-
- 100% - Changed Files in TS/TSX -
-
-
- - -
-

✨ Best Practices Implemented

- -
-
-

State Management

-
    -
  • Redux Toolkit (official)
  • -
  • 4 typed slices
  • -
  • 6 createAsyncThunk actions
  • -
  • 21 centralized selectors
  • -
  • Type-safe hooks
  • -
-
- -
-

Performance

-
    -
  • 18x memo() wrappers
  • -
  • 26x useCallback()
  • -
  • 9x useMemo()
  • -
  • 48x useAppSelector
  • -
  • Original LOC −48.29%
  • -
-
- -
-

Code Quality

-
    -
  • Single Responsibility
  • -
  • DRY Principle
  • -
  • 100% TypeScript
  • -
  • 18 displayName identifiers
  • -
  • 0 build errors
  • -
-
- -
-

Architecture

-
    -
  • 7 custom hooks
  • -
  • 9 utility modules
  • -
  • 10 new components
  • -
  • Store/hooks/utils layers
  • -
  • Typed selector & thunk usage
  • -
-
-
-
- - - -
- - - - diff --git a/content-gen/src/app/frontend/src/api/httpClient.ts b/content-gen/src/app/frontend/src/api/httpClient.ts deleted file mode 100644 index 6aaf83b48..000000000 --- a/content-gen/src/app/frontend/src/api/httpClient.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Centralized HTTP client with interceptors. - * - * - Singleton — use the default `httpClient` export everywhere. - * - Request interceptors automatically attach auth headers - * (X-Ms-Client-Principal-Id) so callers never need to remember. - * - Response interceptors provide uniform error handling. - * - Built-in query-param serialization, configurable timeout, and base URL. - */ - -/* ------------------------------------------------------------------ */ -/* Types */ -/* ------------------------------------------------------------------ */ - -/** Options accepted by every request method. */ -export interface RequestOptions extends Omit { - /** Query parameters – appended to the URL automatically. */ - params?: Record; - /** Per-request timeout in ms (default: client-level `timeout`). */ - timeout?: number; -} - -type RequestInterceptor = (url: string, init: RequestInit) => RequestInit | Promise; -type ResponseInterceptor = (response: Response) => Response | Promise; - -/* ------------------------------------------------------------------ */ -/* HttpClient */ -/* ------------------------------------------------------------------ */ - -export class HttpClient { - private baseUrl: string; - private defaultTimeout: number; - private requestInterceptors: RequestInterceptor[] = []; - private responseInterceptors: ResponseInterceptor[] = []; - - constructor(baseUrl = '', timeout = 60_000) { - this.baseUrl = baseUrl; - this.defaultTimeout = timeout; - } - - /* ---------- interceptor registration ---------- */ - - onRequest(fn: RequestInterceptor): void { - this.requestInterceptors.push(fn); - } - - onResponse(fn: ResponseInterceptor): void { - this.responseInterceptors.push(fn); - } - - /* ---------- public request helpers ---------- */ - - async get(path: string, opts: RequestOptions = {}): Promise { - const res = await this.request(path, { ...opts, method: 'GET' }); - return res.json() as Promise; - } - - async post(path: string, body?: unknown, opts: RequestOptions = {}): Promise { - const res = await this.request(path, { - ...opts, - method: 'POST', - body: body != null ? JSON.stringify(body) : undefined, - headers: { - ...(body != null ? { 'Content-Type': 'application/json' } : {}), - ...opts.headers, - }, - }); - return res.json() as Promise; - } - - async put(path: string, body?: unknown, opts: RequestOptions = {}): Promise { - const res = await this.request(path, { - ...opts, - method: 'PUT', - body: body != null ? JSON.stringify(body) : undefined, - headers: { - ...(body != null ? { 'Content-Type': 'application/json' } : {}), - ...opts.headers, - }, - }); - return res.json() as Promise; - } - - async delete(path: string, opts: RequestOptions = {}): Promise { - const res = await this.request(path, { ...opts, method: 'DELETE' }); - return res.json() as Promise; - } - - /** - * Low-level request that returns the raw `Response`. - * Useful for streaming (SSE) endpoints where the caller needs `response.body`. - */ - async raw(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise { - return this.request(path, opts); - } - - /* ---------- internal plumbing ---------- */ - - private buildUrl(path: string, params?: Record): string { - const url = `${this.baseUrl}${path}`; - if (!params) return url; - - const qs = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - qs.set(key, String(value)); - } - } - const queryString = qs.toString(); - return queryString ? `${url}?${queryString}` : url; - } - - private async request(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise { - const { params, timeout, ...fetchOpts } = opts; - const url = this.buildUrl(path, params); - const effectiveTimeout = timeout ?? this.defaultTimeout; - - // Build the init object - let init: RequestInit = { ...fetchOpts }; - - // Run request interceptors - for (const interceptor of this.requestInterceptors) { - init = await interceptor(url, init); - } - - // Timeout via AbortController (merged with caller-supplied signal) - const timeoutCtrl = new AbortController(); - const callerSignal = init.signal; - - // If caller already passed a signal, listen for its abort - if (callerSignal) { - if (callerSignal.aborted) { - timeoutCtrl.abort(callerSignal.reason); - } else { - callerSignal.addEventListener('abort', () => timeoutCtrl.abort(callerSignal.reason), { once: true }); - } - } - - const timer = effectiveTimeout > 0 - ? setTimeout(() => timeoutCtrl.abort(new DOMException('Request timed out', 'TimeoutError')), effectiveTimeout) - : undefined; - - init.signal = timeoutCtrl.signal; - - try { - let response = await fetch(url, init); - - // Run response interceptors - for (const interceptor of this.responseInterceptors) { - response = await interceptor(response); - } - - return response; - } finally { - if (timer !== undefined) clearTimeout(timer); - } - } -} - -/* ------------------------------------------------------------------ */ -/* Singleton instance with default interceptors */ -/* ------------------------------------------------------------------ */ - -const httpClient = new HttpClient('/api'); - -/** - * Client for Azure platform endpoints (/.auth/me, etc.) — no base URL prefix. - * Shares the same interceptor pattern but targets the host root. - */ -export const platformClient = new HttpClient('', 10_000); - -// ---- request interceptor: auth headers ---- -httpClient.onRequest(async (_url, init) => { - const headers = new Headers(init.headers); - - // Attach userId from Redux store (lazy import to avoid circular deps). - // Falls back to 'anonymous' if store isn't ready yet. - try { - const { store } = await import('../store/store'); - const state = store?.getState?.(); - const userId: string = state?.app?.userId ?? 'anonymous'; - headers.set('X-Ms-Client-Principal-Id', userId); - } catch { - headers.set('X-Ms-Client-Principal-Id', 'anonymous'); - } - - return { ...init, headers }; -}); - -// ---- response interceptor: uniform error handling ---- -httpClient.onResponse((response) => { - if (!response.ok) { - // Don't throw for streaming endpoints — callers handle those manually. - // Clone so the body remains readable for callers that want custom handling. - const cloned = response.clone(); - console.error( - `[httpClient] ${response.status} ${response.statusText} – ${cloned.url}`, - ); - } - return response; -}); - -export default httpClient; diff --git a/content-gen/src/app/frontend/src/api/index.ts b/content-gen/src/app/frontend/src/api/index.ts deleted file mode 100644 index b0c229ce9..000000000 --- a/content-gen/src/app/frontend/src/api/index.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * API service for interacting with the Content Generation backend - */ - -import type { - CreativeBrief, - Product, - AgentResponse, - ParsedBriefResponse, - AppConfig, -} from '../types'; -import httpClient from './httpClient'; -export { default as httpClient } from './httpClient'; -import { getGenerationStage } from '../utils'; - -/** Normalize optional userId to a safe fallback. */ -function normalizeUserId(userId?: string): string { - return userId || 'anonymous'; -} - -/** - * Get application configuration including feature flags - */ -export async function getAppConfig(): Promise { - return httpClient.get('/config'); -} - -/** - * Parse a free-text creative brief into structured format - */ -export async function parseBrief( - briefText: string, - conversationId?: string, - userId?: string, - signal?: AbortSignal -): Promise { - return httpClient.post('/chat', { - message: briefText, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }, { signal }); -} - -/** - * Confirm a parsed creative brief - */ -export async function confirmBrief( - brief: CreativeBrief, - conversationId?: string, - userId?: string -): Promise<{ status: string; conversation_id: string; brief: CreativeBrief }> { - return httpClient.post('/brief/confirm', { - brief, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }); -} - -/** - * Select or modify products via natural language - */ -export async function selectProducts( - request: string, - currentProducts: Product[], - conversationId?: string, - userId?: string, - signal?: AbortSignal -): Promise<{ products: Product[]; action: string; message: string; conversation_id: string }> { - return httpClient.post('/chat', { - message: request, - current_products: currentProducts, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }, { signal }); -} - -/** - * Stream chat messages from the agent orchestration. - * - * Note: The /chat endpoint returns JSON (not SSE), so we perform a standard - * POST request and yield the single AgentResponse result. - */ -export async function* streamChat( - message: string, - conversationId?: string, - userId?: string, - signal?: AbortSignal -): AsyncGenerator { - const result = await httpClient.post( - '/chat', - { - message, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }, - { signal }, - ); - - // Preserve async-iterator interface by yielding the single JSON response. - yield result; -} - -/** - * Generate content from a confirmed brief - */ -export async function* streamGenerateContent( - brief: CreativeBrief, - products?: Product[], - generateImages: boolean = true, - conversationId?: string, - userId?: string, - signal?: AbortSignal -): AsyncGenerator { - // Use polling-based approach for reliability with long-running tasks - const startData = await httpClient.post<{ task_id: string }>('/generate/start', { - brief, - products: products || [], - generate_images: generateImages, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }, { signal }); - const taskId = startData.task_id; - - // 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 statusData = await httpClient.get<{ status: string; result?: unknown; error?: string }>( - `/generate/status/${taskId}`, - { signal }, - ); - - 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') { - const elapsedSeconds = attempts; - const { stage, message: stageMessage } = getGenerationStage(elapsedSeconds); - - // Send status update every second for smoother progress - yield { - type: 'heartbeat', - content: stageMessage, - count: stage, - elapsed: elapsedSeconds, - is_final: false, - } as AgentResponse; - } - } catch (error) { - // Continue polling on transient errors - if (attempts >= maxAttempts) { - throw error; - } - } - } - - throw new Error('Generation timed out after 10 minutes'); -} -/** - * Regenerate image with a modification request - * Used when user wants to change the generated image after initial content generation - */ -export async function* streamRegenerateImage( - modificationRequest: string, - _brief: CreativeBrief, - products?: Product[], - _previousImagePrompt?: string, - conversationId?: string, - userId?: string, - signal?: AbortSignal -): AsyncGenerator { - // Image regeneration uses the unified /chat endpoint with MODIFY_IMAGE intent, - // which returns a task_id for polling via /generate/status. - const startData = await httpClient.post<{ action_type: string; data: { task_id: string; poll_url: string }; conversation_id: string }>( - '/chat', - { - message: modificationRequest, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - selected_products: products || [], - has_generated_content: true, - }, - { signal }, - ); - - const taskId = startData.data?.task_id; - if (!taskId) { - // If no task_id, the response is the final result itself - yield { type: 'agent_response', content: JSON.stringify(startData), is_final: true } as AgentResponse; - return; - } - - yield { type: 'status', content: 'Regeneration started...', is_final: false } as AgentResponse; - - let attempts = 0; - const maxAttempts = 600; - const pollInterval = 1000; - - while (attempts < maxAttempts) { - if (signal?.aborted) { - throw new DOMException('Regeneration cancelled by user', 'AbortError'); - } - - await new Promise(resolve => setTimeout(resolve, pollInterval)); - attempts++; - - if (signal?.aborted) { - throw new DOMException('Regeneration cancelled by user', 'AbortError'); - } - - 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; - } else if (statusData.status === 'failed') { - throw new Error(statusData.error || 'Regeneration failed'); - } else { - const { stage, message: stageMessage } = getGenerationStage(attempts); - yield { type: 'heartbeat', content: stageMessage, count: stage, elapsed: attempts, is_final: false } as AgentResponse; - } - } - - throw new Error('Regeneration timed out after 10 minutes'); -} \ No newline at end of file diff --git a/content-gen/src/app/frontend/src/hooks/index.ts b/content-gen/src/app/frontend/src/hooks/index.ts deleted file mode 100644 index f23f96430..000000000 --- a/content-gen/src/app/frontend/src/hooks/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Barrel export for all custom hooks. - * Import hooks from '../hooks' instead of individual files. - */ -export { useAutoScroll } from './useAutoScroll'; -export { useChatOrchestrator } from './useChatOrchestrator'; -export { useContentGeneration } from './useContentGeneration'; -export { useConversationActions } from './useConversationActions'; -export { useCopyToClipboard } from './useCopyToClipboard'; -export { useWindowSize } from './useWindowSize'; diff --git a/content-gen/src/app/frontend/src/hooks/useAutoScroll.ts b/content-gen/src/app/frontend/src/hooks/useAutoScroll.ts deleted file mode 100644 index 8b1a2a9d1..000000000 --- a/content-gen/src/app/frontend/src/hooks/useAutoScroll.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useRef } from 'react'; - -/** - * Scrolls a sentinel element into view whenever any dependency changes. - * - * @param deps - React dependency list that triggers the scroll. - * @returns A ref to attach to a zero-height element at the bottom of the - * scrollable container (the "scroll anchor"). - * - * @example - * ```tsx - * const endRef = useAutoScroll([messages, isLoading]); - * return ( - *
- * {messages.map(m => )} - *
- *
- * ); - * ``` - */ -export function useAutoScroll(deps: React.DependencyList) { - const endRef = useRef(null); - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { - endRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, deps); - - return endRef; -} diff --git a/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts b/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts deleted file mode 100644 index f4edc088c..000000000 --- a/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { useCallback, type MutableRefObject } from 'react'; - -import type { AgentResponse, GeneratedContent } from '../types'; -import { createMessage, createErrorMessage, matchesAnyKeyword, createNameSwapper } from '../utils'; -import { - useAppDispatch, - useAppSelector, - selectConversationId, - selectUserId, - selectPendingBrief, - selectConfirmedBrief, - selectAwaitingClarification, - selectSelectedProducts, - selectAvailableProducts, - selectGeneratedContent, - addMessage, - setIsLoading, - setGenerationStatus, - GenerationStatus, - setPendingBrief, - setConfirmedBrief, - setAwaitingClarification, - setSelectedProducts, - setGeneratedContent, - incrementHistoryRefresh, - selectConversationTitle, - setConversationTitle, -} from '../store'; -import type { AppDispatch } from '../store'; - -/* ------------------------------------------------------------------ */ -/* Shared helper — consumes a streamChat generator and dispatches */ -/* the final assistant message. Used by branches 1-b, 3-b, 4-b. */ -/* ------------------------------------------------------------------ */ - -async function consumeStreamChat( - stream: AsyncGenerator, - dispatch: AppDispatch, -): Promise { - let fullContent = ''; - let currentAgent = ''; - let messageAdded = false; - - for await (const response of stream) { - if (response.type === 'agent_response') { - fullContent = response.content; - currentAgent = response.agent || ''; - if ((response.is_final || response.requires_user_input) && !messageAdded) { - dispatch(addMessage(createMessage('assistant', fullContent, currentAgent))); - messageAdded = true; - } - } else if (response.type === 'error') { - dispatch( - addMessage( - createMessage( - 'assistant', - response.content || 'An error occurred while processing your request.', - ), - ), - ); - messageAdded = true; - } - } -} - -/* ------------------------------------------------------------------ */ -/* Hook */ -/* ------------------------------------------------------------------ */ - -/** - * Orchestrates the entire "send a message" flow. - * - * Depending on the current conversation phase it will: - * - refine a pending brief (PlanningAgent) - * - answer a general question while a brief is pending (streamChat) - * - forward a product-selection request (ProductAgent) - * - regenerate an image (ImageAgent) - * - parse a new creative brief (PlanningAgent) - * - fall through to generic chat (streamChat) - * - * All Redux reads/writes happen inside the hook so the caller is kept - * thin and declarative. - * - * @param abortControllerRef Shared ref that lets the parent (or sibling - * hooks) cancel the in-flight request. - * @returns `{ sendMessage }` — the callback to wire into `ChatPanel`. - */ -export function useChatOrchestrator( - abortControllerRef: MutableRefObject, -) { - const dispatch = useAppDispatch(); - const conversationId = useAppSelector(selectConversationId); - const userId = useAppSelector(selectUserId); - const pendingBrief = useAppSelector(selectPendingBrief); - const confirmedBrief = useAppSelector(selectConfirmedBrief); - const awaitingClarification = useAppSelector(selectAwaitingClarification); - const selectedProducts = useAppSelector(selectSelectedProducts); - const availableProducts = useAppSelector(selectAvailableProducts); - const generatedContent = useAppSelector(selectGeneratedContent); - const conversationTitle = useAppSelector(selectConversationTitle); - - const sendMessage = useCallback( - async (content: string) => { - dispatch(addMessage(createMessage('user', content))); - dispatch(setIsLoading(true)); - - // Create new abort controller for this request - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - // Dynamic imports to keep the initial bundle lean - const { streamChat, parseBrief, selectProducts } = await import( - '../api' - ); - - /* ---------------------------------------------------------- */ - /* Branch 1 – pending brief, not yet confirmed */ - /* ---------------------------------------------------------- */ - if (pendingBrief && !confirmedBrief) { - const refinementKeywords = [ - 'change', 'update', 'modify', 'add', 'remove', 'delete', - 'set', 'make', 'should be', - ] as const; - const isRefinement = matchesAnyKeyword(content, refinementKeywords); - - if (isRefinement || awaitingClarification) { - // --- 1-a Refine the brief -------------------------------- - const refinementPrompt = `Current creative brief:\n${JSON.stringify(pendingBrief, null, 2)}\n\nUser requested change: ${content}\n\nPlease update the brief accordingly and return the complete updated brief.`; - - dispatch(setGenerationStatus(GenerationStatus.UPDATING_BRIEF)); - const parsed = await parseBrief( - refinementPrompt, - conversationId, - userId, - signal, - ); - - if (parsed.generated_title && !conversationTitle) { - dispatch(setConversationTitle(parsed.generated_title)); - } - - if (parsed.brief) { - dispatch(setPendingBrief(parsed.brief)); - } - - if (parsed.requires_clarification && parsed.clarifying_questions) { - dispatch(setAwaitingClarification(true)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage('assistant', parsed.clarifying_questions, 'PlanningAgent'), - ), - ); - } else { - dispatch(setAwaitingClarification(false)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage( - 'assistant', - "I've updated the brief based on your feedback. Please review the changes above. Let me know if you'd like any other modifications, or click **Confirm Brief** when you're satisfied.", - 'PlanningAgent', - ), - ), - ); - } - } else { - // --- 1-b General question while brief is pending ----------- - dispatch(setGenerationStatus(GenerationStatus.PROCESSING_QUESTION)); - await consumeStreamChat( - streamChat(content, conversationId, userId, signal), - dispatch, - ); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - } - - /* ---------------------------------------------------------- */ - /* Branch 2 – brief confirmed, in product selection */ - /* ---------------------------------------------------------- */ - } else if (confirmedBrief && !generatedContent) { - dispatch(setGenerationStatus(GenerationStatus.FINDING_PRODUCTS)); - const result = await selectProducts( - content, - selectedProducts, - conversationId, - userId, - signal, - ); - dispatch(setSelectedProducts(result.products || [])); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage( - 'assistant', - result.message || 'Products updated.', - 'ProductAgent', - ), - ), - ); - - /* ---------------------------------------------------------- */ - /* Branch 3 – content generated, post-generation phase */ - /* ---------------------------------------------------------- */ - } else if (generatedContent && confirmedBrief) { - const imageModificationKeywords = [ - 'change', 'modify', 'update', 'replace', 'show', 'display', - 'use', 'instead', 'different', 'another', 'make it', 'make the', - 'kitchen', 'dining', 'living', 'bedroom', 'bathroom', 'outdoor', - 'office', 'room', 'scene', 'setting', 'background', 'style', - 'color', 'lighting', - ] as const; - const isImageModification = matchesAnyKeyword(content, imageModificationKeywords); - - if (isImageModification) { - // --- 3-a Regenerate image -------------------------------- - const { streamRegenerateImage } = await import('../api'); - dispatch( - setGenerationStatus(GenerationStatus.REGENERATING_IMAGE), - ); - - let responseData: GeneratedContent | null = null; - let messageContent = ''; - - // Detect if user mentions a different product - const mentionedProduct = availableProducts.find((p) => - content.toLowerCase().includes(p.product_name.toLowerCase()), - ); - const productsForRequest = mentionedProduct - ? [mentionedProduct] - : selectedProducts; - - const previousPrompt = - generatedContent.image_content?.prompt_used; - - for await (const response of streamRegenerateImage( - content, - confirmedBrief, - productsForRequest, - previousPrompt, - conversationId, - userId, - signal, - )) { - if (response.type === 'heartbeat') { - dispatch( - setGenerationStatus({ - status: GenerationStatus.POLLING, - label: response.message || 'Regenerating image...', - }), - ); - } else if ( - response.type === 'agent_response' && - response.is_final - ) { - try { - const parsedContent = JSON.parse(response.content); - - if ( - parsedContent.image_url || - parsedContent.image_base64 - ) { - // Replace old product name in text_content when switching - const swapName = createNameSwapper( - selectedProducts[0]?.product_name, - mentionedProduct?.product_name, - ); - const tc = generatedContent.text_content; - - responseData = { - ...generatedContent, - text_content: mentionedProduct - ? { - ...tc, - headline: swapName?.(tc?.headline) ?? tc?.headline, - body: swapName?.(tc?.body) ?? tc?.body, - tagline: swapName?.(tc?.tagline) ?? tc?.tagline, - cta_text: swapName?.(tc?.cta_text) ?? tc?.cta_text, - } - : tc, - image_content: { - ...generatedContent.image_content, - image_url: - parsedContent.image_url || - generatedContent.image_content?.image_url, - image_base64: parsedContent.image_base64, - prompt_used: - parsedContent.image_prompt || - generatedContent.image_content?.prompt_used, - }, - }; - dispatch(setGeneratedContent(responseData)); - - if (mentionedProduct) { - dispatch(setSelectedProducts([mentionedProduct])); - } - - // Update confirmed brief to include the modification - const updatedBrief = { - ...confirmedBrief, - visual_guidelines: `${confirmedBrief.visual_guidelines}. User modification: ${content}`, - }; - dispatch(setConfirmedBrief(updatedBrief)); - - messageContent = - parsedContent.message || - 'Image regenerated with your requested changes.'; - } else if (parsedContent.error) { - messageContent = parsedContent.error; - } else { - messageContent = - parsedContent.message || 'I processed your request.'; - } - } catch { - messageContent = - response.content || 'Image regenerated.'; - } - } else if (response.type === 'error') { - messageContent = - response.content || - 'An error occurred while regenerating the image.'; - } - } - - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage(createMessage('assistant', messageContent, 'ImageAgent')), - ); - } else { - // --- 3-b General question after content generation -------- - dispatch(setGenerationStatus(GenerationStatus.PROCESSING_REQUEST)); - await consumeStreamChat( - streamChat(content, conversationId, userId, signal), - dispatch, - ); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - } - - /* ---------------------------------------------------------- */ - /* Branch 4 – default: initial flow */ - /* ---------------------------------------------------------- */ - } else { - const briefKeywords = [ - 'campaign', 'marketing', 'target audience', 'objective', - 'deliverable', - ] as const; - const isBriefLike = matchesAnyKeyword(content, briefKeywords); - - if (isBriefLike && !confirmedBrief) { - // --- 4-a Parse as creative brief -------------------------- - dispatch(setGenerationStatus(GenerationStatus.ANALYZING_BRIEF)); - const parsed = await parseBrief( - content, - conversationId, - userId, - signal, - ); - - if (parsed.generated_title && !conversationTitle) { - dispatch(setConversationTitle(parsed.generated_title)); - } - - if (parsed.rai_blocked) { - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage('assistant', parsed.message, 'ContentSafety'), - ), - ); - } else if ( - parsed.requires_clarification && - parsed.clarifying_questions - ) { - if (parsed.brief) { - dispatch(setPendingBrief(parsed.brief)); - } - dispatch(setAwaitingClarification(true)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage( - 'assistant', - parsed.clarifying_questions, - 'PlanningAgent', - ), - ), - ); - } else { - if (parsed.brief) { - dispatch(setPendingBrief(parsed.brief)); - } - dispatch(setAwaitingClarification(false)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage( - 'assistant', - "I've parsed your creative brief. Please review the details below and let me know if you'd like to make any changes. You can say things like \"change the target audience to...\" or \"add a call to action...\". When everything looks good, click **Confirm Brief** to proceed.", - 'PlanningAgent', - ), - ), - ); - } - } else { - // --- 4-b Generic chat ----------------------------------- - dispatch(setGenerationStatus(GenerationStatus.PROCESSING_REQUEST)); - await consumeStreamChat( - streamChat(content, conversationId, userId, signal), - dispatch, - ); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - } - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - dispatch(addMessage(createMessage('assistant', 'Generation stopped.'))); - } else { - dispatch( - addMessage( - createErrorMessage( - 'Sorry, there was an error processing your request. Please try again.', - ), - ), - ); - } - } finally { - dispatch(setIsLoading(false)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - abortControllerRef.current = null; - dispatch(incrementHistoryRefresh()); - } - }, - [ - conversationId, - userId, - confirmedBrief, - pendingBrief, - selectedProducts, - generatedContent, - availableProducts, - dispatch, - awaitingClarification, - conversationTitle, - abortControllerRef, - ], - ); - - return { sendMessage }; -} diff --git a/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts b/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts deleted file mode 100644 index 895c20025..000000000 --- a/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useCallback, type MutableRefObject } from 'react'; - -import { createMessage, createErrorMessage, buildGeneratedContent } from '../utils'; -import { - useAppDispatch, - useAppSelector, - selectConfirmedBrief, - selectSelectedProducts, - selectConversationId, - selectUserId, - addMessage, - setIsLoading, - setGenerationStatus, - GenerationStatus, - setGeneratedContent, -} from '../store'; - -/** - * Handles the full content-generation lifecycle (start → poll → result) - * and exposes a way to abort the in-flight request. - * - * @param abortControllerRef Shared ref so the UI can cancel either - * chat-orchestration **or** content-generation with one button. - */ -export function useContentGeneration( - abortControllerRef: MutableRefObject, -) { - const dispatch = useAppDispatch(); - const confirmedBrief = useAppSelector(selectConfirmedBrief); - const selectedProducts = useAppSelector(selectSelectedProducts); - const conversationId = useAppSelector(selectConversationId); - const userId = useAppSelector(selectUserId); - - /** Kick off polling-based content generation. */ - const generateContent = useCallback(async () => { - if (!confirmedBrief) return; - - dispatch(setIsLoading(true)); - dispatch(setGenerationStatus(GenerationStatus.STARTING_GENERATION)); - - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - const { streamGenerateContent } = await import('../api'); - - for await (const response of streamGenerateContent( - confirmedBrief, - selectedProducts, - true, - conversationId, - userId, - signal, - )) { - // Heartbeat → update the status bar - if (response.type === 'heartbeat') { - const statusMessage = response.content || 'Generating content...'; - const elapsed = (response as { elapsed?: number }).elapsed || 0; - dispatch(setGenerationStatus({ - status: GenerationStatus.POLLING, - label: `${statusMessage} (${elapsed}s)`, - })); - continue; - } - - if (response.is_final && response.type !== 'error') { - dispatch(setGenerationStatus(GenerationStatus.PROCESSING_RESULTS)); - try { - const rawContent = JSON.parse(response.content); - const genContent = buildGeneratedContent(rawContent); - dispatch(setGeneratedContent(genContent)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - } catch { - // Content parse failure — non-critical, generation result may be malformed - } - } else if (response.type === 'error') { - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch(addMessage(createErrorMessage( - `Error generating content: ${response.content}`, - ))); - } - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - dispatch(addMessage(createMessage('assistant', 'Content generation stopped.'))); - } else { - dispatch(addMessage(createErrorMessage( - 'Sorry, there was an error generating content. Please try again.', - ))); - } - } finally { - dispatch(setIsLoading(false)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - abortControllerRef.current = null; - } - }, [ - confirmedBrief, - selectedProducts, - conversationId, - dispatch, - userId, - abortControllerRef, - ]); - - /** Abort whichever request is currently in-flight. */ - const stopGeneration = useCallback(() => { - abortControllerRef.current?.abort(); - }, [abortControllerRef]); - - return { generateContent, stopGeneration }; -} diff --git a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts deleted file mode 100644 index 5dd8e87b9..000000000 --- a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { useCallback } from 'react'; - -import type { ChatMessage, Product, CreativeBrief } from '../types'; -import { createMessage, buildGeneratedContent } from '../utils'; -import { httpClient } from '../api'; -import { - useAppDispatch, - useAppSelector, - selectUserId, - selectConversationId, - selectPendingBrief, - selectSelectedProducts, - resetChat, - resetContent, - setConversationId, - setConversationTitle, - setMessages, - addMessage, - setPendingBrief, - setConfirmedBrief, - setAwaitingClarification, - setSelectedProducts, - setAvailableProducts, - setGeneratedContent, - toggleChatHistory, -} from '../store'; - -/* ------------------------------------------------------------------ */ -/* Hook */ -/* ------------------------------------------------------------------ */ - -/** - * Encapsulates every conversation-level user action: - * - * - Loading a saved conversation from history - * - Starting a brand-new conversation - * - Confirming / cancelling a creative brief - * - Starting over with products - * - Toggling a product selection - * - Toggling the chat-history sidebar - * - * All Redux reads/writes are internal so the consumer stays declarative. - */ -export function useConversationActions() { - const dispatch = useAppDispatch(); - const userId = useAppSelector(selectUserId); - const conversationId = useAppSelector(selectConversationId); - const pendingBrief = useAppSelector(selectPendingBrief); - const selectedProducts = useAppSelector(selectSelectedProducts); - - /* ------------------------------------------------------------ */ - /* Select (load) a conversation from history */ - /* ------------------------------------------------------------ */ - const selectConversation = useCallback( - async (selectedConversationId: string) => { - try { - const data = await httpClient.get<{ - messages?: { - role: string; - content: string; - timestamp?: string; - agent?: string; - }[]; - brief?: unknown; - generated_content?: Record; - }>(`/conversations/${selectedConversationId}`, { - params: { user_id: userId }, - }); - - dispatch(setConversationId(selectedConversationId)); - dispatch(setConversationTitle(null)); // Will use title from conversation list - - const loadedMessages: ChatMessage[] = (data.messages || []).map( - (m, index) => ({ - id: `${selectedConversationId}-${index}`, - role: m.role as 'user' | 'assistant', - content: m.content, - timestamp: m.timestamp || new Date().toISOString(), - agent: m.agent, - }), - ); - dispatch(setMessages(loadedMessages)); - dispatch(setPendingBrief(null)); - dispatch(setAwaitingClarification(false)); - dispatch( - setConfirmedBrief( - (data.brief as CreativeBrief) || null, - ), - ); - - // Restore availableProducts so product/color name detection works - // when regenerating images in a restored conversation - if (data.brief) { - try { - const productsData = await httpClient.get<{ - products?: Product[]; - }>('/products'); - dispatch(setAvailableProducts(productsData.products || [])); - } catch { - // Non-critical — product load failure for restored conversation - } - } - - if (data.generated_content) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const gc = data.generated_content as any; - const restoredContent = buildGeneratedContent(gc, true); - dispatch(setGeneratedContent(restoredContent)); - - if ( - gc.selected_products && - Array.isArray(gc.selected_products) - ) { - dispatch(setSelectedProducts(gc.selected_products)); - } else { - dispatch(setSelectedProducts([])); - } - } else { - dispatch(setGeneratedContent(null)); - dispatch(setSelectedProducts([])); - } - } catch { - // Error loading conversation — swallowed silently - } - }, - [userId, dispatch], - ); - - /* ------------------------------------------------------------ */ - /* Start a new conversation */ - /* ------------------------------------------------------------ */ - const newConversation = useCallback(() => { - dispatch(resetChat(undefined)); - dispatch(resetContent()); - }, [dispatch]); - - /* ------------------------------------------------------------ */ - /* Brief lifecycle */ - /* ------------------------------------------------------------ */ - const confirmBrief = useCallback(async () => { - if (!pendingBrief) return; - - try { - const { confirmBrief: confirmBriefApi } = await import('../api'); - await confirmBriefApi(pendingBrief, conversationId, userId); - dispatch(setConfirmedBrief(pendingBrief)); - dispatch(setPendingBrief(null)); - dispatch(setAwaitingClarification(false)); - - const productsData = await httpClient.get<{ products?: Product[] }>( - '/products', - ); - dispatch(setAvailableProducts(productsData.products || [])); - - dispatch( - addMessage( - createMessage( - 'assistant', - "Great! Your creative brief has been confirmed. Here are the available products for your campaign. Select the ones you'd like to feature, or tell me what you're looking for.", - 'ProductAgent', - ), - ), - ); - } catch { - // Error confirming brief — swallowed silently - } - }, [conversationId, userId, pendingBrief, dispatch]); - - const cancelBrief = useCallback(() => { - dispatch(setPendingBrief(null)); - dispatch(setAwaitingClarification(false)); - dispatch( - addMessage( - createMessage( - 'assistant', - 'No problem. Please provide your creative brief again or ask me any questions.', - ), - ), - ); - }, [dispatch]); - - /* ------------------------------------------------------------ */ - /* Product actions */ - /* ------------------------------------------------------------ */ - const selectProduct = 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 — replace any existing selection - dispatch(setSelectedProducts([product])); - } - }, - [selectedProducts, dispatch], - ); - - /* ------------------------------------------------------------ */ - /* Sidebar toggle */ - /* ------------------------------------------------------------ */ - const toggleHistory = useCallback(() => { - dispatch(toggleChatHistory()); - }, [dispatch]); - - return { - selectConversation, - newConversation, - confirmBrief, - cancelBrief, - selectProduct, - toggleHistory, - }; -} diff --git a/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts b/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts deleted file mode 100644 index 5887c9532..000000000 --- a/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; - -/** - * Copy text to the clipboard and expose a transient `copied` flag. - * - * @param resetTimeout - Milliseconds before `copied` resets to `false` (default 2 000). - * @returns `{ copied, copy }` — `copy(text)` writes to the clipboard and - * flips `copied` to `true` for `resetTimeout` ms. - */ -export function useCopyToClipboard(resetTimeout = 2000) { - const [copied, setCopied] = useState(false); - const timerRef = useRef>(); - - const copy = useCallback( - (text: string) => { - navigator.clipboard.writeText(text).catch(() => { - // Clipboard write failure — non-critical - }); - setCopied(true); - clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setCopied(false), resetTimeout); - }, - [resetTimeout], - ); - - // Cleanup on unmount - useEffect(() => { - return () => clearTimeout(timerRef.current); - }, []); - - return { copied, copy }; -} diff --git a/content-gen/src/app/frontend/src/hooks/useWindowSize.ts b/content-gen/src/app/frontend/src/hooks/useWindowSize.ts deleted file mode 100644 index da662eb41..000000000 --- a/content-gen/src/app/frontend/src/hooks/useWindowSize.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useState, useEffect } from 'react'; - -/** - * Returns the current window inner-width, updating on resize. - * Falls back to 1200 during SSR. - */ -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/content-gen/src/app/frontend/src/store/appSlice.ts b/content-gen/src/app/frontend/src/store/appSlice.ts deleted file mode 100644 index 952b6ae1e..000000000 --- a/content-gen/src/app/frontend/src/store/appSlice.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * App slice — application-level state (user info, config, feature flags, UI toggles). - * createSlice + createAsyncThunk replaces manual dispatch + string constants. - * Granular selectors — each component subscribes only to the state it needs. - */ -import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; - -/* ------------------------------------------------------------------ */ -/* Generation-status enum */ -/* ------------------------------------------------------------------ */ - -/** - * Finite set of generation-status values. Components that read - * `generationStatus` can compare against these constants instead of - * relying on magic strings. - * - * `IDLE` means "no status to display". Every other member maps to a - * user-facing label via {@link GENERATION_STATUS_LABELS}. - */ -export enum GenerationStatus { - IDLE = '', - UPDATING_BRIEF = 'UPDATING_BRIEF', - PROCESSING_QUESTION = 'PROCESSING_QUESTION', - FINDING_PRODUCTS = 'FINDING_PRODUCTS', - REGENERATING_IMAGE = 'REGENERATING_IMAGE', - PROCESSING_REQUEST = 'PROCESSING_REQUEST', - ANALYZING_BRIEF = 'ANALYZING_BRIEF', - STARTING_GENERATION = 'STARTING_GENERATION', - PROCESSING_RESULTS = 'PROCESSING_RESULTS', - /** Used for heartbeat polling where the label is dynamic. */ - POLLING = 'POLLING', -} - -/** Display strings shown in the UI for each status. */ -const GENERATION_STATUS_LABELS: Record = { - [GenerationStatus.IDLE]: '', - [GenerationStatus.UPDATING_BRIEF]: 'Updating creative brief...', - [GenerationStatus.PROCESSING_QUESTION]: 'Processing your question...', - [GenerationStatus.FINDING_PRODUCTS]: 'Finding products...', - [GenerationStatus.REGENERATING_IMAGE]: 'Regenerating image with your changes...', - [GenerationStatus.PROCESSING_REQUEST]: 'Processing your request...', - [GenerationStatus.ANALYZING_BRIEF]: 'Analyzing creative brief...', - [GenerationStatus.STARTING_GENERATION]: 'Starting content generation...', - [GenerationStatus.PROCESSING_RESULTS]: 'Processing results...', - [GenerationStatus.POLLING]: 'Generating content...', -}; - -/* ------------------------------------------------------------------ */ -/* Async Thunks */ -/* ------------------------------------------------------------------ */ - -export const fetchAppConfig = createAsyncThunk( - 'app/fetchAppConfig', - async () => { - const { getAppConfig } = await import('../api'); - const config = await getAppConfig(); - return config; - }, -); - -export const fetchUserInfo = createAsyncThunk( - 'app/fetchUserInfo', - async () => { - const { platformClient } = await import('../api/httpClient'); - const response = await platformClient.raw('/.auth/me'); - if (!response.ok) return { userId: 'anonymous', userName: '' }; - - const payload = await response.json(); - const claims: { typ: string; val: string }[] = payload[0]?.user_claims || []; - - const objectId = claims.find( - (c) => c.typ === 'http://schemas.microsoft.com/identity/claims/objectidentifier', - )?.val || 'anonymous'; - - const name = claims.find((c) => c.typ === 'name')?.val || ''; - - return { userId: objectId, userName: name }; - }, -); - -/* ------------------------------------------------------------------ */ -/* Slice */ -/* ------------------------------------------------------------------ */ - -interface AppState { - userId: string; - userName: string; - isLoading: boolean; - imageGenerationEnabled: boolean; - showChatHistory: boolean; - /** Current generation status enum value. */ - generationStatus: GenerationStatus; - /** Dynamic label override (used with GenerationStatus.POLLING). */ - generationStatusLabel: string; -} - -const initialState: AppState = { - userId: '', - userName: '', - isLoading: false, - imageGenerationEnabled: true, - showChatHistory: true, - generationStatus: GenerationStatus.IDLE, - generationStatusLabel: '', -}; - -const appSlice = createSlice({ - name: 'app', - initialState, - reducers: { - setIsLoading(state, action: PayloadAction) { - state.isLoading = action.payload; - }, - setGenerationStatus( - state, - action: PayloadAction, - ) { - if (typeof action.payload === 'string') { - state.generationStatus = action.payload; - state.generationStatusLabel = GENERATION_STATUS_LABELS[action.payload]; - } else { - state.generationStatus = action.payload.status; - state.generationStatusLabel = action.payload.label; - } - }, - 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) => { - state.imageGenerationEnabled = true; // default when fetch fails - }) - .addCase(fetchUserInfo.fulfilled, (state, action) => { - state.userId = action.payload.userId; - state.userName = action.payload.userName; - }) - .addCase(fetchUserInfo.rejected, (state) => { - state.userId = 'anonymous'; - state.userName = ''; - }); - }, -}); - -export const { setIsLoading, setGenerationStatus, toggleChatHistory } = - appSlice.actions; -export default appSlice.reducer; diff --git a/content-gen/src/app/frontend/src/store/chatHistorySlice.ts b/content-gen/src/app/frontend/src/store/chatHistorySlice.ts deleted file mode 100644 index b97b14e31..000000000 --- a/content-gen/src/app/frontend/src/store/chatHistorySlice.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Chat history slice — conversation list CRUD via async thunks. - * createAsyncThunk replaces inline fetch + manual state updates in ChatHistory.tsx. - * Granular selectors for each piece of history state. - */ -import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; -import { httpClient } from '../api'; - -export interface ConversationSummary { - id: string; - title: string; - lastMessage: string; - timestamp: string; - messageCount: number; -} - -interface ChatHistoryState { - conversations: ConversationSummary[]; - isLoading: boolean; - error: string | null; - showAll: boolean; - isClearAllDialogOpen: boolean; - isClearing: boolean; -} - -const initialState: ChatHistoryState = { - conversations: [], - isLoading: true, - error: null, - showAll: false, - isClearAllDialogOpen: false, - isClearing: false, -}; - -/* ------------------------------------------------------------------ */ -/* Async Thunks */ -/* ------------------------------------------------------------------ */ - -export const fetchConversations = createAsyncThunk( - 'chatHistory/fetchConversations', - async () => { - const data = await httpClient.get<{ conversations?: ConversationSummary[] }>('/conversations'); - return (data.conversations || []) as ConversationSummary[]; - }, -); - -export const deleteConversation = createAsyncThunk( - 'chatHistory/deleteConversation', - async (conversationId: string) => { - await httpClient.delete(`/conversations/${conversationId}`); - return conversationId; - }, -); - -export const renameConversation = createAsyncThunk( - 'chatHistory/renameConversation', - async ({ conversationId, newTitle }: { conversationId: string; newTitle: string }) => { - await httpClient.put(`/conversations/${conversationId}`, { title: newTitle }); - return { conversationId, newTitle }; - }, -); - -export const clearAllConversations = createAsyncThunk( - 'chatHistory/clearAllConversations', - async () => { - await httpClient.delete('/conversations'); - }, -); - -/* ------------------------------------------------------------------ */ -/* Slice */ -/* ------------------------------------------------------------------ */ - -const chatHistorySlice = createSlice({ - name: 'chatHistory', - initialState, - reducers: { - setShowAll(state, action: PayloadAction) { - state.showAll = action.payload; - }, - setIsClearAllDialogOpen(state, action: PayloadAction) { - state.isClearAllDialogOpen = action.payload; - }, - }, - extraReducers: (builder) => { - builder - // Fetch - .addCase(fetchConversations.pending, (state) => { - state.isLoading = true; - state.error = null; - }) - .addCase(fetchConversations.fulfilled, (state, action) => { - state.conversations = action.payload; - state.isLoading = false; - }) - .addCase(fetchConversations.rejected, (state) => { - state.error = 'Unable to load conversation history'; - state.conversations = []; - state.isLoading = false; - }) - // Delete single - .addCase(deleteConversation.fulfilled, (state, action) => { - state.conversations = state.conversations.filter((c) => c.id !== action.payload); - }) - // Rename - .addCase(renameConversation.fulfilled, (state, action) => { - const conv = state.conversations.find((c) => c.id === action.payload.conversationId); - if (conv) conv.title = action.payload.newTitle; - }) - // Clear all - .addCase(clearAllConversations.pending, (state) => { - state.isClearing = true; - }) - .addCase(clearAllConversations.fulfilled, (state) => { - state.conversations = []; - state.isClearing = false; - state.isClearAllDialogOpen = false; - }) - .addCase(clearAllConversations.rejected, (state) => { - state.isClearing = false; - }); - }, -}); - -export const { setShowAll, setIsClearAllDialogOpen } = - chatHistorySlice.actions; -export default chatHistorySlice.reducer; diff --git a/content-gen/src/app/frontend/src/store/chatSlice.ts b/content-gen/src/app/frontend/src/store/chatSlice.ts deleted file mode 100644 index 71b25330e..000000000 --- a/content-gen/src/app/frontend/src/store/chatSlice.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Chat slice — conversation state, messages, clarification flow. - * Typed createSlice replaces scattered useState-based state in App.tsx. - * Granular selectors for each piece of chat 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[]; - awaitingClarification: boolean; - historyRefreshTrigger: number; -} - -const initialState: ChatState = { - conversationId: uuidv4(), - conversationTitle: null, - messages: [], - awaitingClarification: false, - 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; - }, - setMessages(state, action: PayloadAction) { - state.messages = action.payload; - }, - addMessage(state, action: PayloadAction) { - state.messages.push(action.payload); - }, - setAwaitingClarification(state, action: PayloadAction) { - state.awaitingClarification = action.payload; - }, - incrementHistoryRefresh(state) { - state.historyRefreshTrigger += 1; - }, - /** Reset chat to a fresh conversation. Optionally provide a new ID. */ - resetChat(state, action: PayloadAction) { - state.conversationId = action.payload ?? uuidv4(); - state.conversationTitle = null; - state.messages = []; - state.awaitingClarification = false; - }, - }, -}); - -export const { - setConversationId, - setConversationTitle, - setMessages, - addMessage, - setAwaitingClarification, - incrementHistoryRefresh, - resetChat, -} = chatSlice.actions; -export default chatSlice.reducer; diff --git a/content-gen/src/app/frontend/src/store/contentSlice.ts b/content-gen/src/app/frontend/src/store/contentSlice.ts deleted file mode 100644 index 15736efd5..000000000 --- a/content-gen/src/app/frontend/src/store/contentSlice.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Content slice — creative brief, product selection, generated content. - * Typed createSlice with granular selectors. - */ -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/content-gen/src/app/frontend/src/store/hooks.ts b/content-gen/src/app/frontend/src/store/hooks.ts deleted file mode 100644 index c9c663095..000000000 --- a/content-gen/src/app/frontend/src/store/hooks.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Typed Redux hooks for type-safe store access throughout the app. - * Use useAppDispatch and useAppSelector instead of raw useDispatch/useSelector. - */ -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/content-gen/src/app/frontend/src/store/index.ts b/content-gen/src/app/frontend/src/store/index.ts deleted file mode 100644 index 1a7a98623..000000000 --- a/content-gen/src/app/frontend/src/store/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Barrel export for the Redux store. - * Import everything you need from '../store'. - */ -export { store } from './store'; -export type { RootState, AppDispatch } from './store'; -export { useAppDispatch, useAppSelector } from './hooks'; - -// App slice – actions, thunks & enums -export { - fetchAppConfig, - fetchUserInfo, - setIsLoading, - setGenerationStatus, - toggleChatHistory, - GenerationStatus, -} from './appSlice'; - -// Chat slice – actions -export { - setConversationId, - setConversationTitle, - setMessages, - addMessage, - setAwaitingClarification, - incrementHistoryRefresh, - resetChat, -} from './chatSlice'; - -// Content slice – actions -export { - setPendingBrief, - setConfirmedBrief, - setSelectedProducts, - setAvailableProducts, - setGeneratedContent, - resetContent, -} from './contentSlice'; - -// Chat History slice – actions & thunks -export { - fetchConversations, - deleteConversation, - renameConversation, - clearAllConversations, - setShowAll, - setIsClearAllDialogOpen, -} from './chatHistorySlice'; -export type { ConversationSummary } from './chatHistorySlice'; - -// All selectors (centralized to avoid circular store ↔ slice imports) -export { - selectUserId, - selectUserName, - selectIsLoading, - selectGenerationStatusLabel, - selectImageGenerationEnabled, - selectShowChatHistory, - selectConversationId, - selectConversationTitle, - selectMessages, - selectAwaitingClarification, - selectHistoryRefreshTrigger, - selectPendingBrief, - selectConfirmedBrief, - selectSelectedProducts, - selectAvailableProducts, - selectGeneratedContent, - selectConversations, - selectIsHistoryLoading, - selectHistoryError, - selectShowAll, - selectIsClearAllDialogOpen, - selectIsClearing, -} from './selectors'; diff --git a/content-gen/src/app/frontend/src/store/selectors.ts b/content-gen/src/app/frontend/src/store/selectors.ts deleted file mode 100644 index a837844c5..000000000 --- a/content-gen/src/app/frontend/src/store/selectors.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * All Redux selectors in one place. - * Importing RootState here (and ONLY here) avoids the circular dependency - * between store.ts ↔ slice files that confuses VS Code's TypeScript server. - */ -import type { RootState } from './store'; - -/* ---- App selectors ---- */ -export const selectUserId = (state: RootState) => state.app.userId; -export const selectUserName = (state: RootState) => state.app.userName; -export const selectIsLoading = (state: RootState) => state.app.isLoading; -export const selectGenerationStatusLabel = (state: RootState) => state.app.generationStatusLabel; -export const selectImageGenerationEnabled = (state: RootState) => state.app.imageGenerationEnabled; -export const selectShowChatHistory = (state: RootState) => state.app.showChatHistory; - -/* ---- Chat selectors ---- */ -export const selectConversationId = (state: RootState) => state.chat.conversationId; -export const selectConversationTitle = (state: RootState) => state.chat.conversationTitle; -export const selectMessages = (state: RootState) => state.chat.messages; -export const selectAwaitingClarification = (state: RootState) => state.chat.awaitingClarification; -export const selectHistoryRefreshTrigger = (state: RootState) => state.chat.historyRefreshTrigger; - -/* ---- Content selectors ---- */ -export const selectPendingBrief = (state: RootState) => state.content.pendingBrief; -export const selectConfirmedBrief = (state: RootState) => state.content.confirmedBrief; -export const selectSelectedProducts = (state: RootState) => state.content.selectedProducts; -export const selectAvailableProducts = (state: RootState) => state.content.availableProducts; -export const selectGeneratedContent = (state: RootState) => state.content.generatedContent; - -/* ---- Chat History selectors ---- */ -export const selectConversations = (state: RootState) => state.chatHistory.conversations; -export const selectIsHistoryLoading = (state: RootState) => state.chatHistory.isLoading; -export const selectHistoryError = (state: RootState) => state.chatHistory.error; -export const selectShowAll = (state: RootState) => state.chatHistory.showAll; -export const selectIsClearAllDialogOpen = (state: RootState) => state.chatHistory.isClearAllDialogOpen; -export const selectIsClearing = (state: RootState) => state.chatHistory.isClearing; diff --git a/content-gen/src/app/frontend/src/store/store.ts b/content-gen/src/app/frontend/src/store/store.ts deleted file mode 100644 index 81e515742..000000000 --- a/content-gen/src/app/frontend/src/store/store.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Redux store — central state for the application. - * configureStore combines all domain-specific slices. - */ -import { configureStore } from '@reduxjs/toolkit'; -import appReducer from './appSlice'; -import chatReducer from './chatSlice'; -import contentReducer from './contentSlice'; -import chatHistoryReducer from './chatHistorySlice'; - -export const store = configureStore({ - reducer: { - app: appReducer, - chat: chatReducer, - content: contentReducer, - chatHistory: chatHistoryReducer, - }, -}); - -export type RootState = ReturnType; -export type AppDispatch = typeof store.dispatch; diff --git a/content-gen/src/app/frontend/src/utils/briefFields.ts b/content-gen/src/app/frontend/src/utils/briefFields.ts deleted file mode 100644 index beb44a88e..000000000 --- a/content-gen/src/app/frontend/src/utils/briefFields.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Brief-field metadata shared between BriefReview and ConfirmedBriefView. - * - * Eliminates the duplicated field-label arrays. - */ -import type { CreativeBrief } from '../types'; - -/** - * Canonical map from `CreativeBrief` keys to user-friendly labels. - * Used by BriefReview (completeness gauges) and ConfirmedBriefView. - */ -export const BRIEF_FIELD_LABELS: Record = { - overview: 'Overview', - objectives: 'Objectives', - target_audience: 'Target Audience', - key_message: 'Key Message', - tone_and_style: 'Tone & Style', - deliverable: 'Deliverable', - timelines: 'Timelines', - visual_guidelines: 'Visual Guidelines', - cta: 'Call to Action', -}; - -/** - * Display order for brief fields in review UIs. - * - * The first element in each tuple is the `CreativeBrief` key, the second - * is the UI label (which may differ slightly from `BRIEF_FIELD_LABELS` - * for contextual reasons, e.g. "Campaign Objective" vs "Overview"). - */ -export const BRIEF_DISPLAY_ORDER: { 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' }, -]; - -/** - * The canonical list of all nine brief field keys, in display order. - */ -export const BRIEF_FIELD_KEYS: (keyof CreativeBrief)[] = BRIEF_DISPLAY_ORDER.map((f) => f.key); diff --git a/content-gen/src/app/frontend/src/utils/contentErrors.ts b/content-gen/src/app/frontend/src/utils/contentErrors.ts deleted file mode 100644 index dc9ca497a..000000000 --- a/content-gen/src/app/frontend/src/utils/contentErrors.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Detect whether an error message originates from a content-safety filter. - */ -export function 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()), - ); -} - -/** - * Return a user-friendly title/description for a generation 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.', - }; -} diff --git a/content-gen/src/app/frontend/src/utils/contentParsing.ts b/content-gen/src/app/frontend/src/utils/contentParsing.ts deleted file mode 100644 index e59ac85e3..000000000 --- a/content-gen/src/app/frontend/src/utils/contentParsing.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Content parsing utilities — raw API response → typed domain objects. - * - * Centralizes the duplicated `textContent` string-to-object parsing, - * image URL resolution (blob rewriting, base64 fallback), and the - * `GeneratedContent` assembly that was copy-pasted across - * useContentGeneration, useConversationActions, and useChatOrchestrator. - */ -import type { GeneratedContent } from '../types'; - -/* ------------------------------------------------------------------ */ -/* Internal helpers (not exported — reduces public API surface) */ -/* ------------------------------------------------------------------ */ - -/** - * Rewrite Azure Blob Storage URLs to the application's proxy endpoint - * so the browser can fetch images without CORS issues. - */ -function rewriteBlobUrl(url: string): string { - if (!url.includes('blob.core.windows.net')) return url; - const parts = url.split('/'); - const filename = parts[parts.length - 1]; - const convId = parts[parts.length - 2]; - return `/api/images/${convId}/${filename}`; -} - -/* ------------------------------------------------------------------ */ -/* Parsing helpers (module-internal — not re-exported) */ -/* ------------------------------------------------------------------ */ - -/** - * Parse `text_content` which may arrive as a JSON string or an object. - * Returns an object with known fields, or `undefined` if unusable. - */ -function parseTextContent( - raw: unknown, -): { headline?: string; body?: string; cta_text?: string; tagline?: string } | undefined { - let textContent = raw; - - if (typeof textContent === 'string') { - try { - textContent = JSON.parse(textContent); - } catch { - // Not valid JSON — treat as unusable - return undefined; - } - } - - if (typeof textContent !== 'object' || textContent === null) return undefined; - - const tc = textContent as Record; - return { - headline: tc.headline as string | undefined, - body: tc.body as string | undefined, - cta_text: (tc.cta_text ?? tc.cta) as string | undefined, - tagline: tc.tagline as string | undefined, - }; -} - -/** - * Resolve the best available image URL from a raw API response. - * - * Priority: explicit `image_url` (with blob rewrite) → base64 data URI. - * Pass `rewriteBlobs: true` (default) when restoring from a saved - * conversation; `false` when the response just came from the live API. - */ -function resolveImageUrl( - raw: { image_url?: string; image_base64?: string }, - rewriteBlobs = false, -): string | undefined { - let url = raw.image_url; - if (url && rewriteBlobs) { - url = rewriteBlobUrl(url); - } - if (url) return url; - if (raw.image_base64) return `data:image/png;base64,${raw.image_base64}`; - return undefined; -} - -/** - * Build a fully-typed `GeneratedContent` from an arbitrary raw API payload. - * - * @param raw The parsed JSON object from the backend. - * @param rewriteBlobs Pass `true` when restoring from a saved conversation - * so Azure Blob URLs get proxied. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function buildGeneratedContent(raw: any, rewriteBlobs = false): GeneratedContent { - const textContent = parseTextContent(raw.text_content); - const imageUrl = resolveImageUrl(raw, rewriteBlobs); - - return { - text_content: textContent, - image_content: - imageUrl || raw.image_prompt - ? { - image_url: imageUrl, - prompt_used: raw.image_prompt, - alt_text: raw.image_revised_prompt || 'Generated marketing image', - } - : undefined, - violations: raw.violations || [], - requires_modification: raw.requires_modification || false, - error: raw.error, - image_error: raw.image_error, - text_error: raw.text_error, - }; -} diff --git a/content-gen/src/app/frontend/src/utils/downloadImage.ts b/content-gen/src/app/frontend/src/utils/downloadImage.ts deleted file mode 100644 index 08e752c20..000000000 --- a/content-gen/src/app/frontend/src/utils/downloadImage.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Download the generated marketing image with a product-name / tagline - * banner composited at the bottom. - * - * Falls back to a plain download when canvas compositing fails. - */ -export async function downloadImage( - imageUrl: string, - productName?: string, - tagline?: string, -): Promise { - try { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) 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 the image at the top - ctx.drawImage(img, 0, 0); - - // White banner at the bottom - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, img.height, img.width, bannerHeight); - - ctx.strokeStyle = '#e5e5e5'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, img.height); - ctx.lineTo(img.width, img.height); - ctx.stroke(); - - // Headline text - const headlineText = productName || 'Your Product'; - const headlineFontSize = Math.max(18, Math.min(36, img.width * 0.04)); - const taglineFontSize = Math.max(12, Math.min(20, img.width * 0.025)); - - ctx.font = `600 ${headlineFontSize}px Georgia, serif`; - ctx.fillStyle = '#1a1a1a'; - ctx.fillText( - headlineText, - padding, - img.height + padding + headlineFontSize * 0.8, - img.width - padding * 2, - ); - - // Tagline - if (tagline) { - 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); - const link = document.createElement('a'); - link.href = url; - link.download = 'generated-marketing-image.png'; - link.click(); - URL.revokeObjectURL(url); - } - }, 'image/png'); - }; - - img.onerror = () => { - plainDownload(imageUrl); - }; - - img.src = imageUrl; - } catch { - plainDownload(imageUrl); - } -} - -function plainDownload(url: string) { - const link = document.createElement('a'); - link.href = url; - link.download = 'generated-image.png'; - link.click(); -} diff --git a/content-gen/src/app/frontend/src/utils/generationStages.ts b/content-gen/src/app/frontend/src/utils/generationStages.ts deleted file mode 100644 index 03399bc0d..000000000 --- a/content-gen/src/app/frontend/src/utils/generationStages.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Generation progress stage mapping. - * - * Pure function that converts elapsed seconds into a human-readable - * stage label + ordinal — used by the polling loop in `streamGenerateContent`. - */ - -export interface GenerationStage { - /** Ordinal stage index (0–5) for progress indicators. */ - stage: number; - /** Human-readable status message. */ - message: string; -} - -/** - * Map elapsed seconds to the current generation stage. - * - * Typical generation timeline: - * - 0 – 10 s → Briefing analysis - * - 10 – 25 s → Copy generation - * - 25 – 35 s → Image prompt creation - * - 35 – 55 s → Image generation - * - 55 – 70 s → Compliance check - * - 70 s+ → Finalizing - */ -export function getGenerationStage(elapsedSeconds: number): GenerationStage { - if (elapsedSeconds < 10) return { stage: 0, message: 'Analyzing creative brief...' }; - if (elapsedSeconds < 25) return { stage: 1, message: 'Generating marketing copy...' }; - if (elapsedSeconds < 35) return { stage: 2, message: 'Creating image prompt...' }; - if (elapsedSeconds < 55) return { stage: 3, message: 'Generating image with AI...' }; - if (elapsedSeconds < 70) return { stage: 4, message: 'Running compliance check...' }; - return { stage: 5, message: 'Finalizing content...' }; -} diff --git a/content-gen/src/app/frontend/src/utils/index.ts b/content-gen/src/app/frontend/src/utils/index.ts deleted file mode 100644 index 94ba048c3..000000000 --- a/content-gen/src/app/frontend/src/utils/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Barrel export for all utility modules. - * - * Import everything you need from '../utils'. - */ - -// Message factories & formatting -export { createMessage, createErrorMessage } from './messageUtils'; - -// Content parsing (raw API → typed domain objects) -export { buildGeneratedContent } from './contentParsing'; - -// SSE stream parser -export { parseSSEStream } from './sseParser'; - -// Generation progress stages -export { getGenerationStage } from './generationStages'; - -// Brief-field metadata -export { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS } from './briefFields'; - -// String utilities -export { createNameSwapper, matchesAnyKeyword } from './stringUtils'; - -// Content error detection -export { isContentFilterError, getErrorMessage } from './contentErrors'; - -// Image download -export { downloadImage } from './downloadImage'; - -// Shared UI constants -export const AI_DISCLAIMER = 'AI-generated content may be incorrect'; diff --git a/content-gen/src/app/frontend/src/utils/messageUtils.ts b/content-gen/src/app/frontend/src/utils/messageUtils.ts deleted file mode 100644 index 45a7ea5ac..000000000 --- a/content-gen/src/app/frontend/src/utils/messageUtils.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Message utilities — ChatMessage factory and formatting helpers. - * - * Replaces duplicated `msg()` helpers in useChatOrchestrator and - * useConversationActions with a single, tested source of truth. - */ -import { v4 as uuidv4 } from 'uuid'; -import type { ChatMessage } from '../types'; - -/** - * Create a `ChatMessage` literal with a fresh UUID and ISO timestamp. - */ -export function createMessage( - role: 'user' | 'assistant', - content: string, - agent?: string, -): ChatMessage { - return { - id: uuidv4(), - role, - content, - agent, - timestamp: new Date().toISOString(), - }; -} - -/** - * Shorthand for creating an assistant error message. - * Consolidates the repeated `createMessage('assistant', errorText)` pattern - * used in error catch blocks across multiple hooks. - */ -export function createErrorMessage(content: string): ChatMessage { - return createMessage('assistant', content); -} diff --git a/content-gen/src/app/frontend/src/utils/sseParser.ts b/content-gen/src/app/frontend/src/utils/sseParser.ts deleted file mode 100644 index 7767c0b5e..000000000 --- a/content-gen/src/app/frontend/src/utils/sseParser.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * SSE (Server-Sent Events) stream parser. - * - * Eliminates the duplicated TextDecoder + buffer + line-split logic - * that was copy-pasted in `streamChat` and `streamRegenerateImage`. - */ -import type { AgentResponse } from '../types'; - -/** - * Parse an SSE stream from a `ReadableStreamDefaultReader` into an - * `AsyncGenerator` of `AgentResponse` objects. - * - * Protocol assumed: - * - Events delimited by `\n\n` - * - Each event starts with `data: ` - * - `data: [DONE]` terminates the stream - * - * @param reader The reader obtained via `response.body.getReader()` - */ -export async function* parseSSEStream( - reader: ReadableStreamDefaultReader, -): AsyncGenerator { - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6); - if (data === '[DONE]') { - return; - } - try { - yield JSON.parse(data) as AgentResponse; - } catch { - // Malformed SSE frame — skip silently - } - } - } - } -} diff --git a/content-gen/src/app/frontend/src/utils/stringUtils.ts b/content-gen/src/app/frontend/src/utils/stringUtils.ts deleted file mode 100644 index 387e07ff5..000000000 --- a/content-gen/src/app/frontend/src/utils/stringUtils.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * String utilities — regex escaping, name swapping, keyword matching. - * - * Extracts the duplicated keyword-matching pattern and the regex-escape + - * swapName closure from useChatOrchestrator into reusable, testable functions. - */ - -/** - * Escape a string so it can be safely embedded in a `RegExp` pattern. - * @internal — only used by `createNameSwapper` within this module. - */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Create a function that replaces all case-insensitive occurrences of - * `oldName` with `newName` in a string. - * - * Returns `undefined` if no swap is possible (names are the same, etc.). - */ -export function createNameSwapper( - oldName: string | undefined, - newName: string | undefined, -): ((text?: string) => string | undefined) | undefined { - if (!oldName || !newName || oldName === newName) return undefined; - - const regex = new RegExp(escapeRegex(oldName), 'gi'); - return (text?: string) => { - if (!text) return text; - return text.replace(regex, () => newName); - }; -} - -/** - * Check whether `text` contains **any** of the given keywords - * (case-insensitive substring match). - * - * Used for intent classification (brief detection, refinement detection, - * image modification detection) repeated 3× in useChatOrchestrator. - */ -export function matchesAnyKeyword(text: string, keywords: readonly string[]): boolean { - const lower = text.toLowerCase(); - return keywords.some((kw) => lower.includes(kw)); -} diff --git a/docs/AZD_DEPLOYMENT.md b/docs/AZD_DEPLOYMENT.md index f8e8c5f3c..019483934 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 +cd src/app/frontend npm install npm run build ``` diff --git a/docs/TECHNICAL_GUIDE.md b/docs/TECHNICAL_GUIDE.md index fcedf6f38..69a28172b 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 +cd src/app/frontend npm install npm run dev ``` diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 19245cc40..cc411a2f3 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -131,15 +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\App" + Set-Location "$ProjectDir\src\frontend" npm install npm run build # Copy built files to server directory - New-Item -ItemType Directory -Force "$ProjectDir\src\App\server\static" | Out-Null - Copy-Item -Path "$ProjectDir\src\App\static\*" -Destination "$ProjectDir\src\App\server\static\" -Recurse -Force + Copy-Item -Path "$ProjectDir\src\static\*" -Destination "$ProjectDir\src\frontend-server\static\" -Recurse -Force - Set-Location "$ProjectDir\src\App\server" + Set-Location "$ProjectDir\src\frontend-server" # Create deployment package if (Test-Path "frontend-deploy.zip") { diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 1883e1d00..efd2b51b9 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -129,15 +129,14 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then echo "Step 3: Building and deploying frontend..." echo "==========================================" - cd "$PROJECT_DIR/src/App" + cd "$PROJECT_DIR/src/frontend" npm install npm run build # Copy built files to server directory - mkdir -p "$PROJECT_DIR/src/App/server/static" - cp -r "$PROJECT_DIR/src/App/static/"* "$PROJECT_DIR/src/App/server/static/" + cp -r "$PROJECT_DIR/src/static/"* "$PROJECT_DIR/src/frontend-server/static/" - cd "$PROJECT_DIR/src/App/server" + cd "$PROJECT_DIR/src/frontend-server" # Create deployment package rm -f frontend-deploy.zip diff --git a/scripts/local_dev.ps1 b/scripts/local_dev.ps1 index b4d45ba96..7319aca56 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" +$FrontendDir = Join-Path $SrcDir "app\frontend" # 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 31b342fc8..4f1c084ac 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_DIR="$SRC_DIR/app/frontend" # Default ports BACKEND_PORT=${BACKEND_PORT:-5000} diff --git a/src/App/src/App.tsx b/src/App/src/App.tsx deleted file mode 100644 index 28d241c28..000000000 --- a/src/App/src/App.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useEffect, useRef } from 'react'; - -import { ChatPanel } from './components/ChatPanel'; -import { ChatHistory } from './components/ChatHistory'; -import { AppHeader } from './components/AppHeader'; -import { - useAppDispatch, - useAppSelector, - fetchAppConfig, - fetchUserInfo, - selectUserName, - selectShowChatHistory, -} from './store'; -import { useChatOrchestrator, useContentGeneration, useConversationActions } from './hooks'; - - -function App() { - const dispatch = useAppDispatch(); - const userName = useAppSelector(selectUserName); - const showChatHistory = useAppSelector(selectShowChatHistory); - - // Shared abort controller for chat & content-generation - const abortControllerRef = useRef(null); - - // Business-logic hooks - const { sendMessage } = useChatOrchestrator(abortControllerRef); - const { generateContent, stopGeneration } = useContentGeneration(abortControllerRef); - const { - selectConversation, - newConversation, - confirmBrief, - cancelBrief, - selectProduct, - toggleHistory, - } = useConversationActions(); - - // Fetch app config & current user on mount - useEffect(() => { - dispatch(fetchAppConfig()); - dispatch(fetchUserInfo()); - }, [dispatch]); - - return ( -
- {/* Header */} - - - {/* Main Content */} -
- {/* Chat Panel - main area */} -
- -
- - {/* Chat History Sidebar - RIGHT side */} - {showChatHistory && ( -
- -
- )} -
-
- ); -} - -export default App; diff --git a/src/App/src/api/httpClient.ts b/src/App/src/api/httpClient.ts deleted file mode 100644 index 6aaf83b48..000000000 --- a/src/App/src/api/httpClient.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Centralized HTTP client with interceptors. - * - * - Singleton — use the default `httpClient` export everywhere. - * - Request interceptors automatically attach auth headers - * (X-Ms-Client-Principal-Id) so callers never need to remember. - * - Response interceptors provide uniform error handling. - * - Built-in query-param serialization, configurable timeout, and base URL. - */ - -/* ------------------------------------------------------------------ */ -/* Types */ -/* ------------------------------------------------------------------ */ - -/** Options accepted by every request method. */ -export interface RequestOptions extends Omit { - /** Query parameters – appended to the URL automatically. */ - params?: Record; - /** Per-request timeout in ms (default: client-level `timeout`). */ - timeout?: number; -} - -type RequestInterceptor = (url: string, init: RequestInit) => RequestInit | Promise; -type ResponseInterceptor = (response: Response) => Response | Promise; - -/* ------------------------------------------------------------------ */ -/* HttpClient */ -/* ------------------------------------------------------------------ */ - -export class HttpClient { - private baseUrl: string; - private defaultTimeout: number; - private requestInterceptors: RequestInterceptor[] = []; - private responseInterceptors: ResponseInterceptor[] = []; - - constructor(baseUrl = '', timeout = 60_000) { - this.baseUrl = baseUrl; - this.defaultTimeout = timeout; - } - - /* ---------- interceptor registration ---------- */ - - onRequest(fn: RequestInterceptor): void { - this.requestInterceptors.push(fn); - } - - onResponse(fn: ResponseInterceptor): void { - this.responseInterceptors.push(fn); - } - - /* ---------- public request helpers ---------- */ - - async get(path: string, opts: RequestOptions = {}): Promise { - const res = await this.request(path, { ...opts, method: 'GET' }); - return res.json() as Promise; - } - - async post(path: string, body?: unknown, opts: RequestOptions = {}): Promise { - const res = await this.request(path, { - ...opts, - method: 'POST', - body: body != null ? JSON.stringify(body) : undefined, - headers: { - ...(body != null ? { 'Content-Type': 'application/json' } : {}), - ...opts.headers, - }, - }); - return res.json() as Promise; - } - - async put(path: string, body?: unknown, opts: RequestOptions = {}): Promise { - const res = await this.request(path, { - ...opts, - method: 'PUT', - body: body != null ? JSON.stringify(body) : undefined, - headers: { - ...(body != null ? { 'Content-Type': 'application/json' } : {}), - ...opts.headers, - }, - }); - return res.json() as Promise; - } - - async delete(path: string, opts: RequestOptions = {}): Promise { - const res = await this.request(path, { ...opts, method: 'DELETE' }); - return res.json() as Promise; - } - - /** - * Low-level request that returns the raw `Response`. - * Useful for streaming (SSE) endpoints where the caller needs `response.body`. - */ - async raw(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise { - return this.request(path, opts); - } - - /* ---------- internal plumbing ---------- */ - - private buildUrl(path: string, params?: Record): string { - const url = `${this.baseUrl}${path}`; - if (!params) return url; - - const qs = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - qs.set(key, String(value)); - } - } - const queryString = qs.toString(); - return queryString ? `${url}?${queryString}` : url; - } - - private async request(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise { - const { params, timeout, ...fetchOpts } = opts; - const url = this.buildUrl(path, params); - const effectiveTimeout = timeout ?? this.defaultTimeout; - - // Build the init object - let init: RequestInit = { ...fetchOpts }; - - // Run request interceptors - for (const interceptor of this.requestInterceptors) { - init = await interceptor(url, init); - } - - // Timeout via AbortController (merged with caller-supplied signal) - const timeoutCtrl = new AbortController(); - const callerSignal = init.signal; - - // If caller already passed a signal, listen for its abort - if (callerSignal) { - if (callerSignal.aborted) { - timeoutCtrl.abort(callerSignal.reason); - } else { - callerSignal.addEventListener('abort', () => timeoutCtrl.abort(callerSignal.reason), { once: true }); - } - } - - const timer = effectiveTimeout > 0 - ? setTimeout(() => timeoutCtrl.abort(new DOMException('Request timed out', 'TimeoutError')), effectiveTimeout) - : undefined; - - init.signal = timeoutCtrl.signal; - - try { - let response = await fetch(url, init); - - // Run response interceptors - for (const interceptor of this.responseInterceptors) { - response = await interceptor(response); - } - - return response; - } finally { - if (timer !== undefined) clearTimeout(timer); - } - } -} - -/* ------------------------------------------------------------------ */ -/* Singleton instance with default interceptors */ -/* ------------------------------------------------------------------ */ - -const httpClient = new HttpClient('/api'); - -/** - * Client for Azure platform endpoints (/.auth/me, etc.) — no base URL prefix. - * Shares the same interceptor pattern but targets the host root. - */ -export const platformClient = new HttpClient('', 10_000); - -// ---- request interceptor: auth headers ---- -httpClient.onRequest(async (_url, init) => { - const headers = new Headers(init.headers); - - // Attach userId from Redux store (lazy import to avoid circular deps). - // Falls back to 'anonymous' if store isn't ready yet. - try { - const { store } = await import('../store/store'); - const state = store?.getState?.(); - const userId: string = state?.app?.userId ?? 'anonymous'; - headers.set('X-Ms-Client-Principal-Id', userId); - } catch { - headers.set('X-Ms-Client-Principal-Id', 'anonymous'); - } - - return { ...init, headers }; -}); - -// ---- response interceptor: uniform error handling ---- -httpClient.onResponse((response) => { - if (!response.ok) { - // Don't throw for streaming endpoints — callers handle those manually. - // Clone so the body remains readable for callers that want custom handling. - const cloned = response.clone(); - console.error( - `[httpClient] ${response.status} ${response.statusText} – ${cloned.url}`, - ); - } - return response; -}); - -export default httpClient; diff --git a/src/App/src/api/index.ts b/src/App/src/api/index.ts deleted file mode 100644 index b0c229ce9..000000000 --- a/src/App/src/api/index.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * API service for interacting with the Content Generation backend - */ - -import type { - CreativeBrief, - Product, - AgentResponse, - ParsedBriefResponse, - AppConfig, -} from '../types'; -import httpClient from './httpClient'; -export { default as httpClient } from './httpClient'; -import { getGenerationStage } from '../utils'; - -/** Normalize optional userId to a safe fallback. */ -function normalizeUserId(userId?: string): string { - return userId || 'anonymous'; -} - -/** - * Get application configuration including feature flags - */ -export async function getAppConfig(): Promise { - return httpClient.get('/config'); -} - -/** - * Parse a free-text creative brief into structured format - */ -export async function parseBrief( - briefText: string, - conversationId?: string, - userId?: string, - signal?: AbortSignal -): Promise { - return httpClient.post('/chat', { - message: briefText, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }, { signal }); -} - -/** - * Confirm a parsed creative brief - */ -export async function confirmBrief( - brief: CreativeBrief, - conversationId?: string, - userId?: string -): Promise<{ status: string; conversation_id: string; brief: CreativeBrief }> { - return httpClient.post('/brief/confirm', { - brief, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }); -} - -/** - * Select or modify products via natural language - */ -export async function selectProducts( - request: string, - currentProducts: Product[], - conversationId?: string, - userId?: string, - signal?: AbortSignal -): Promise<{ products: Product[]; action: string; message: string; conversation_id: string }> { - return httpClient.post('/chat', { - message: request, - current_products: currentProducts, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }, { signal }); -} - -/** - * Stream chat messages from the agent orchestration. - * - * Note: The /chat endpoint returns JSON (not SSE), so we perform a standard - * POST request and yield the single AgentResponse result. - */ -export async function* streamChat( - message: string, - conversationId?: string, - userId?: string, - signal?: AbortSignal -): AsyncGenerator { - const result = await httpClient.post( - '/chat', - { - message, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }, - { signal }, - ); - - // Preserve async-iterator interface by yielding the single JSON response. - yield result; -} - -/** - * Generate content from a confirmed brief - */ -export async function* streamGenerateContent( - brief: CreativeBrief, - products?: Product[], - generateImages: boolean = true, - conversationId?: string, - userId?: string, - signal?: AbortSignal -): AsyncGenerator { - // Use polling-based approach for reliability with long-running tasks - const startData = await httpClient.post<{ task_id: string }>('/generate/start', { - brief, - products: products || [], - generate_images: generateImages, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - }, { signal }); - const taskId = startData.task_id; - - // 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 statusData = await httpClient.get<{ status: string; result?: unknown; error?: string }>( - `/generate/status/${taskId}`, - { signal }, - ); - - 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') { - const elapsedSeconds = attempts; - const { stage, message: stageMessage } = getGenerationStage(elapsedSeconds); - - // Send status update every second for smoother progress - yield { - type: 'heartbeat', - content: stageMessage, - count: stage, - elapsed: elapsedSeconds, - is_final: false, - } as AgentResponse; - } - } catch (error) { - // Continue polling on transient errors - if (attempts >= maxAttempts) { - throw error; - } - } - } - - throw new Error('Generation timed out after 10 minutes'); -} -/** - * Regenerate image with a modification request - * Used when user wants to change the generated image after initial content generation - */ -export async function* streamRegenerateImage( - modificationRequest: string, - _brief: CreativeBrief, - products?: Product[], - _previousImagePrompt?: string, - conversationId?: string, - userId?: string, - signal?: AbortSignal -): AsyncGenerator { - // Image regeneration uses the unified /chat endpoint with MODIFY_IMAGE intent, - // which returns a task_id for polling via /generate/status. - const startData = await httpClient.post<{ action_type: string; data: { task_id: string; poll_url: string }; conversation_id: string }>( - '/chat', - { - message: modificationRequest, - conversation_id: conversationId, - user_id: normalizeUserId(userId), - selected_products: products || [], - has_generated_content: true, - }, - { signal }, - ); - - const taskId = startData.data?.task_id; - if (!taskId) { - // If no task_id, the response is the final result itself - yield { type: 'agent_response', content: JSON.stringify(startData), is_final: true } as AgentResponse; - return; - } - - yield { type: 'status', content: 'Regeneration started...', is_final: false } as AgentResponse; - - let attempts = 0; - const maxAttempts = 600; - const pollInterval = 1000; - - while (attempts < maxAttempts) { - if (signal?.aborted) { - throw new DOMException('Regeneration cancelled by user', 'AbortError'); - } - - await new Promise(resolve => setTimeout(resolve, pollInterval)); - attempts++; - - if (signal?.aborted) { - throw new DOMException('Regeneration cancelled by user', 'AbortError'); - } - - 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; - } else if (statusData.status === 'failed') { - throw new Error(statusData.error || 'Regeneration failed'); - } else { - const { stage, message: stageMessage } = getGenerationStage(attempts); - yield { type: 'heartbeat', content: stageMessage, count: stage, elapsed: attempts, is_final: false } as AgentResponse; - } - } - - throw new Error('Regeneration timed out after 10 minutes'); -} \ No newline at end of file diff --git a/src/App/src/components/AppHeader.tsx b/src/App/src/components/AppHeader.tsx deleted file mode 100644 index 810f0a072..000000000 --- a/src/App/src/components/AppHeader.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { memo } 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'; - -export interface AppHeaderProps { - userName?: string | null; - showChatHistory: boolean; - onToggleChatHistory: () => void; -} - -/** - * Top-level application header with logo, title, history toggle and avatar. - */ -export const AppHeader = memo(function AppHeader({ userName, showChatHistory, onToggleChatHistory }: AppHeaderProps) { - return ( -
-
- Contoso - - Contoso - -
- -
- -
-
- ); -}); -AppHeader.displayName = 'AppHeader'; diff --git a/src/App/src/components/ChatHistory.tsx b/src/App/src/components/ChatHistory.tsx deleted file mode 100644 index c393fdec9..000000000 --- a/src/App/src/components/ChatHistory.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { useEffect, useCallback, useMemo, memo } from 'react'; -import { - Button, - Text, - Spinner, - tokens, - Link, - Menu, - MenuTrigger, - MenuPopover, - MenuList, - MenuItem, - Dialog, - DialogSurface, - DialogTitle, - DialogBody, - DialogActions, - DialogContent, -} from '@fluentui/react-components'; -import { - Chat24Regular, - MoreHorizontal20Regular, - Compose20Regular, - DismissCircle20Regular, -} from '@fluentui/react-icons'; -import { - useAppDispatch, - useAppSelector, - fetchConversations, - deleteConversation, - renameConversation, - clearAllConversations, - setShowAll as setShowAllAction, - setIsClearAllDialogOpen, - selectConversations, - selectIsHistoryLoading, - selectHistoryError, - selectShowAll, - selectIsClearAllDialogOpen, - selectIsClearing, - selectConversationId, - selectConversationTitle, - selectMessages, - selectIsLoading, - selectHistoryRefreshTrigger, -} from '../store'; -import type { ConversationSummary } from '../store'; -import { ConversationItem } from './ConversationItem'; - -interface ChatHistoryProps { - onSelectConversation: (conversationId: string) => void; - onNewConversation: () => void; -} - -export const ChatHistory = memo(function ChatHistory({ - onSelectConversation, - onNewConversation, -}: ChatHistoryProps) { - const dispatch = useAppDispatch(); - const conversations = useAppSelector(selectConversations); - const isLoading = useAppSelector(selectIsHistoryLoading); - const error = useAppSelector(selectHistoryError); - const showAll = useAppSelector(selectShowAll); - const isClearAllDialogOpen = useAppSelector(selectIsClearAllDialogOpen); - const isClearing = useAppSelector(selectIsClearing); - const currentConversationId = useAppSelector(selectConversationId); - const currentConversationTitle = useAppSelector(selectConversationTitle); - const currentMessages = useAppSelector(selectMessages); - const isGenerating = useAppSelector(selectIsLoading); - const refreshTrigger = useAppSelector(selectHistoryRefreshTrigger); - - const INITIAL_COUNT = 5; - - const handleClearAllConversations = useCallback(async () => { - try { - await dispatch(clearAllConversations()).unwrap(); - onNewConversation(); - } catch { - // Error clearing all conversations - } - }, [dispatch, onNewConversation]); - - const handleDeleteConversation = useCallback(async (conversationId: string) => { - try { - await dispatch(deleteConversation(conversationId)).unwrap(); - if (conversationId === currentConversationId) { - onNewConversation(); - } - } catch { - // Error deleting conversation - } - }, [dispatch, currentConversationId, onNewConversation]); - - const handleRenameConversation = useCallback(async (conversationId: string, newTitle: string) => { - try { - await dispatch(renameConversation({ conversationId, newTitle })).unwrap(); - } catch { - // Error renaming conversation - } - }, [dispatch]); - - useEffect(() => { - dispatch(fetchConversations()); - }, [dispatch, refreshTrigger]); - - // Reset showAll when conversations change significantly - useEffect(() => { - dispatch(setShowAllAction(false)); - }, [dispatch, refreshTrigger]); - - // Build the current session conversation summary if it has messages - const currentSessionConversation = useMemo(() => - currentMessages.length > 0 && currentConversationTitle ? { - id: currentConversationId, - title: currentConversationTitle, - lastMessage: currentMessages[currentMessages.length - 1]?.content?.substring(0, 100) || '', - timestamp: new Date().toISOString(), - messageCount: currentMessages.length, - } : null, - [currentMessages, currentConversationId, currentConversationTitle], - ); - - // Merge current session with saved conversations, updating the current one with live data - const displayConversations = useMemo(() => { - const existingIndex = conversations.findIndex(c => c.id === currentConversationId); - - if (existingIndex >= 0 && currentSessionConversation) { - const updated = [...conversations]; - updated[existingIndex] = { - ...updated[existingIndex], - messageCount: currentMessages.length, - lastMessage: currentMessages[currentMessages.length - 1]?.content?.substring(0, 100) || updated[existingIndex].lastMessage, - }; - return updated; - } else if (currentSessionConversation) { - return [currentSessionConversation, ...conversations]; - } - return conversations; - }, [conversations, currentConversationId, currentSessionConversation, currentMessages]); - - const visibleConversations = useMemo( - () => showAll ? displayConversations : displayConversations.slice(0, INITIAL_COUNT), - [showAll, displayConversations], - ); - const hasMore = displayConversations.length > INITIAL_COUNT; - - const handleRefreshConversations = useCallback(() => { - dispatch(fetchConversations()); - }, [dispatch]); - - return ( -
-
- - Chat History - - - - -
- -
- -
- {isLoading ? ( -
- -
- ) : error ? ( -
- {error} - - Retry - -
- ) : displayConversations.length === 0 ? ( -
- - No conversations yet -
- ) : ( - <> - {visibleConversations.map((conversation) => ( - onSelectConversation(conversation.id)} - onDelete={handleDeleteConversation} - onRename={handleRenameConversation} - onRefresh={handleRefreshConversations} - disabled={isGenerating} - /> - ))} - - )} - -
- {hasMore && ( - dispatch(setShowAllAction(!showAll))} - style={{ - fontSize: '13px', - color: isGenerating ? tokens.colorNeutralForegroundDisabled : tokens.colorBrandForeground1, - cursor: isGenerating ? 'not-allowed' : 'pointer', - pointerEvents: isGenerating ? 'none' : 'auto', - }} - > - {showAll ? 'Show less' : 'See all'} - - )} - - - Start new chat - -
-
- - {/* Clear All Confirmation Dialog */} - !isClearing && dispatch(setIsClearAllDialogOpen(data.open))}> - - Clear all chat history - - - - Are you sure you want to delete all chat history? This action cannot be undone and all conversations will be permanently removed. - - - - - - - - - -
- ); -}); - -ChatHistory.displayName = 'ChatHistory'; diff --git a/src/App/src/components/ChatInput.tsx b/src/App/src/components/ChatInput.tsx deleted file mode 100644 index 5a3efb41f..000000000 --- a/src/App/src/components/ChatInput.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { memo, useState, useCallback } from 'react'; -import { - Button, - Text, - Tooltip, - tokens, -} from '@fluentui/react-components'; -import { AI_DISCLAIMER } from '../utils'; -import { - Send20Regular, - Add20Regular, -} from '@fluentui/react-icons'; - -export interface ChatInputProps { - /** Called with the trimmed message text when the user submits. */ - onSendMessage: (message: string) => void; - /** Called when the user clicks the "New chat" button. */ - onNewConversation?: () => void; - /** Disables the input and buttons while a request is in flight. */ - disabled?: boolean; - /** Allows the parent to drive the input value (e.g. from WelcomeCard suggestions). */ - value?: string; - /** Notifies the parent when the user types. */ - onChange?: (value: string) => void; -} - -/** - * Chat input bar with send & new-chat buttons, plus an AI disclaimer. - */ -export const ChatInput = memo(function ChatInput({ - onSendMessage, - onNewConversation, - disabled = false, - value: controlledValue, - onChange: controlledOnChange, -}: ChatInputProps) { - const [internalValue, setInternalValue] = useState(''); - - // Support both controlled & uncontrolled modes - const inputValue = controlledValue ?? internalValue; - const setInputValue = useCallback((v: string) => { - controlledOnChange?.(v); - if (controlledValue === undefined) setInternalValue(v); - }, [controlledOnChange, controlledValue]); - - const handleSubmit = useCallback((e: React.FormEvent) => { - e.preventDefault(); - if (inputValue.trim() && !disabled) { - onSendMessage(inputValue.trim()); - setInputValue(''); - } - }, [inputValue, disabled, onSendMessage, setInputValue]); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); - } - }, [handleSubmit]); - - return ( -
- {/* Input Box */} -
- setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Type a message" - disabled={disabled} - 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 deleted file mode 100644 index a10709cae..000000000 --- a/src/App/src/components/ChatPanel.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useState, useCallback, memo } from 'react'; -import type { Product } 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'; -import { useAutoScroll } from '../hooks'; -import { - useAppSelector, - selectMessages, - selectIsLoading, - selectGenerationStatusLabel, - selectPendingBrief, - selectConfirmedBrief, - selectGeneratedContent, - selectSelectedProducts, - selectAvailableProducts, - selectImageGenerationEnabled, -} from '../store'; - -interface ChatPanelProps { - onSendMessage: (message: string) => void; - onStopGeneration?: () => void; - onBriefConfirm?: () => void; - onBriefCancel?: () => void; - onGenerateContent?: () => void; - onRegenerateContent?: () => void; - onProductSelect?: (product: Product) => void; - onNewConversation?: () => void; -} - -export const ChatPanel = memo(function ChatPanel({ - onSendMessage, - onStopGeneration, - onBriefConfirm, - onBriefCancel, - onGenerateContent, - onRegenerateContent, - onProductSelect, - onNewConversation, -}: ChatPanelProps) { - const messages = useAppSelector(selectMessages); - const isLoading = useAppSelector(selectIsLoading); - const generationStatus = useAppSelector(selectGenerationStatusLabel); - const pendingBrief = useAppSelector(selectPendingBrief); - const confirmedBrief = useAppSelector(selectConfirmedBrief); - const generatedContent = useAppSelector(selectGeneratedContent); - const selectedProducts = useAppSelector(selectSelectedProducts); - const availableProducts = useAppSelector(selectAvailableProducts); - const imageGenerationEnabled = useAppSelector(selectImageGenerationEnabled); - - const [inputValue, setInputValue] = useState(''); - - // Auto-scroll to bottom when messages or state changes - const messagesEndRef = useAutoScroll([ - 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); - }, []); - - 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 */} - -
- ); -}); - -ChatPanel.displayName = 'ChatPanel'; diff --git a/src/App/src/components/ComplianceSection.tsx b/src/App/src/components/ComplianceSection.tsx deleted file mode 100644 index 216a11f60..000000000 --- a/src/App/src/components/ComplianceSection.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { memo } from 'react'; -import { AI_DISCLAIMER } from '../utils'; -import { - Text, - Badge, - Button, - Tooltip, - Accordion, - AccordionItem, - AccordionHeader, - AccordionPanel, - tokens, -} from '@fluentui/react-components'; -import { - ArrowSync20Regular, - CheckmarkCircle20Regular, - Warning20Regular, - Info20Regular, - ErrorCircle20Regular, - Copy20Regular, -} from '@fluentui/react-icons'; -import type { ComplianceViolation } from '../types'; -import { ViolationCard } from './ViolationCard'; - -export interface ComplianceSectionProps { - violations: ComplianceViolation[]; - requiresModification: boolean; - /** Callback to copy generated text. */ - onCopyText: () => void; - /** Callback to regenerate content. */ - onRegenerate: () => void; - /** Whether regeneration is in progress. */ - isLoading?: boolean; - /** Whether the copy-text button shows "Copied!". */ - copied?: boolean; -} - -/** - * Compliance callout (action-needed / review-recommended), status footer - * with badges and actions, and the collapsible violations accordion. - */ -export const ComplianceSection = memo(function ComplianceSection({ - violations, - requiresModification, - onCopyText, - onRegenerate, - isLoading, - copied = false, -}: ComplianceSectionProps) { - return ( - <> - {/* User guidance callout */} - {requiresModification ? ( -
- - Action needed: This content has compliance issues that must be - addressed before use. Please review the details in the Compliance Guidelines - section below and regenerate with modifications, or manually edit the content to - resolve the flagged items. - -
- ) : violations.length > 0 ? ( -
- - Optional review: This content is approved but has minor - suggestions for improvement. You can use it as-is or review the recommendations - in the Compliance Guidelines section below. - -
- ) : null} - - {/* Footer with actions */} -
-
- {requiresModification ? ( - }> - Requires Modification - - ) : violations.length > 0 ? ( - }> - Review Recommended - - ) : ( - } - > - Approved - - )} -
- -
- -
-
- - {/* AI disclaimer */} - - {AI_DISCLAIMER} - - - {/* Collapsible Compliance Accordion */} - {violations.length > 0 && ( - - - -
- {requiresModification ? ( - - ) : violations.some((v) => v.severity === 'error') ? ( - - ) : violations.some((v) => v.severity === 'warning') ? ( - - ) : ( - - )} - - Compliance Guidelines ({violations.length}{' '} - {violations.length === 1 ? 'item' : 'items'}) - -
-
- -
- {violations.map((violation, index) => ( - - ))} -
-
-
-
- )} - - ); -}); -ComplianceSection.displayName = 'ComplianceSection'; diff --git a/src/App/src/components/ConversationItem.tsx b/src/App/src/components/ConversationItem.tsx deleted file mode 100644 index c40d89d1d..000000000 --- a/src/App/src/components/ConversationItem.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import { memo, useState, useEffect, useRef, useCallback } from 'react'; -import { - Button, - Text, - tokens, - Menu, - MenuTrigger, - MenuPopover, - MenuList, - MenuItem, - Input, - Dialog, - DialogSurface, - DialogTitle, - DialogBody, - DialogActions, - DialogContent, -} from '@fluentui/react-components'; -import { - MoreHorizontal20Regular, - Delete20Regular, - Edit20Regular, -} from '@fluentui/react-icons'; -import type { ConversationSummary } from '../store'; - -/* ------------------------------------------------------------------ */ -/* Validation constants & helper */ -/* ------------------------------------------------------------------ */ - -const NAME_MIN_LENGTH = 5; -const NAME_MAX_LENGTH = 50; - -/** Returns an error message, or `''` when the value is valid. */ -function validateConversationName(value: string): string { - const trimmed = value.trim(); - if (trimmed === '') return 'Conversation name cannot be empty or contain only spaces'; - if (trimmed.length < NAME_MIN_LENGTH) return `Conversation name must be at least ${NAME_MIN_LENGTH} characters`; - if (value.length > NAME_MAX_LENGTH) return `Conversation name cannot exceed ${NAME_MAX_LENGTH} characters`; - if (!/[a-zA-Z0-9]/.test(trimmed)) return 'Conversation name must contain at least one letter or number'; - return ''; -} - -export interface ConversationItemProps { - conversation: ConversationSummary; - isActive: boolean; - onSelect: () => void; - onDelete: (conversationId: string) => void; - onRename: (conversationId: string, newTitle: string) => void; - onRefresh: () => void; - disabled?: boolean; -} - -/** - * A single row in the chat-history sidebar — - * title, context-menu (rename / delete) and confirmation dialogs. - */ -export const ConversationItem = memo(function ConversationItem({ - conversation, - isActive, - onSelect, - onDelete, - onRename, - onRefresh, - disabled = false, -}: ConversationItemProps) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [renameValue, setRenameValue] = useState(conversation.title || ''); - const [renameError, setRenameError] = useState(''); - const renameInputRef = useRef(null); - - const handleRenameClick = useCallback(() => { - setRenameValue(conversation.title || ''); - setRenameError(''); - setIsRenameDialogOpen(true); - setIsMenuOpen(false); - }, [conversation.title]); - - const handleRenameConfirm = useCallback(async () => { - const error = validateConversationName(renameValue); - if (error) { - setRenameError(error); - return; - } - - const trimmedValue = renameValue.trim(); - if (trimmedValue === conversation.title) { - setIsRenameDialogOpen(false); - setRenameError(''); - return; - } - - await onRename(conversation.id, trimmedValue); - onRefresh(); - setIsRenameDialogOpen(false); - setRenameError(''); - }, [renameValue, conversation.id, conversation.title, onRename, onRefresh]); - - const handleDeleteClick = useCallback(() => { - setIsDeleteDialogOpen(true); - setIsMenuOpen(false); - }, []); - - const handleDeleteConfirm = useCallback(async () => { - await onDelete(conversation.id); - setIsDeleteDialogOpen(false); - }, [conversation.id, onDelete]); - - useEffect(() => { - if (isRenameDialogOpen && renameInputRef.current) { - renameInputRef.current.focus(); - renameInputRef.current.select(); - } - }, [isRenameDialogOpen]); - - return ( - <> -
- - {conversation.title || 'Untitled'} - - - setIsMenuOpen(data.open)}> - - -
- - {/* Rename dialog */} - setIsRenameDialogOpen(data.open)}> - - Rename conversation - - - { - const newValue = e.target.value; - setRenameValue(newValue); - setRenameError(validateConversationName(newValue)); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && renameValue.trim()) { - handleRenameConfirm(); - } else if (e.key === 'Escape') { - setIsRenameDialogOpen(false); - } - }} - placeholder="Enter conversation name" - style={{ width: '100%' }} - /> - - Maximum {NAME_MAX_LENGTH} characters ({renameValue.length}/{NAME_MAX_LENGTH}) - - {renameError && ( - - {renameError} - - )} - - - - - - - - - - {/* Delete dialog */} - setIsDeleteDialogOpen(data.open)}> - - Delete conversation - - - - Are you sure you want to delete "{conversation.title || 'Untitled'}"? This action - cannot be undone. - - - - - - - - - - - ); -}); -ConversationItem.displayName = 'ConversationItem'; diff --git a/src/App/src/components/ImagePreviewCard.tsx b/src/App/src/components/ImagePreviewCard.tsx deleted file mode 100644 index b4e1ee50a..000000000 --- a/src/App/src/components/ImagePreviewCard.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { memo } from 'react'; -import { - Button, - Text, - Tooltip, - tokens, -} from '@fluentui/react-components'; -import { ArrowDownload20Regular } from '@fluentui/react-icons'; - -export interface ImagePreviewCardProps { - imageUrl: string; - altText?: string; - productName?: string; - tagline?: string; - isSmall?: boolean; - onDownload: () => void; -} - -/** - * Image preview with download button overlay and a product-name / tagline - * text banner below the image. - */ -export const ImagePreviewCard = memo(function ImagePreviewCard({ - imageUrl, - altText = 'Generated marketing image', - productName = 'Your Product', - tagline, - isSmall = false, - onDownload, -}: ImagePreviewCardProps) { - return ( -
- {/* Image container */} -
- {altText} - - -
- - {/* Text banner below image */} -
- - {productName} - - {tagline && ( - - {tagline} - - )} -
-
- ); -}); -ImagePreviewCard.displayName = 'ImagePreviewCard'; diff --git a/src/App/src/components/InlineContentPreview.tsx b/src/App/src/components/InlineContentPreview.tsx deleted file mode 100644 index dc0f27491..000000000 --- a/src/App/src/components/InlineContentPreview.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { memo, useCallback, useMemo } from 'react'; -import { - Text, - Divider, - tokens, -} from '@fluentui/react-components'; -import { ShieldError20Regular } from '@fluentui/react-icons'; -import type { GeneratedContent, Product } from '../types'; -import { useWindowSize, useCopyToClipboard } from '../hooks'; -import { isContentFilterError, getErrorMessage, downloadImage } from '../utils'; -import { ImagePreviewCard } from './ImagePreviewCard'; -import { ComplianceSection } from './ComplianceSection'; - -interface InlineContentPreviewProps { - content: GeneratedContent; - onRegenerate: () => void; - isLoading?: boolean; - selectedProduct?: Product; - imageGenerationEnabled?: boolean; -} - -export const InlineContentPreview = memo(function InlineContentPreview({ - content, - onRegenerate, - isLoading, - selectedProduct, - imageGenerationEnabled = true, -}: InlineContentPreviewProps) { - const { text_content, image_content, violations, requires_modification, error, image_error, text_error } = content; - const { copied, copy } = useCopyToClipboard(); - const windowWidth = useWindowSize(); - - const isSmall = windowWidth < 768; - - const handleCopyText = useCallback(() => { - const textToCopy = [ - text_content?.headline && `✨ ${text_content.headline} ✨`, - text_content?.body, - text_content?.tagline, - ].filter(Boolean).join('\n\n'); - copy(textToCopy); - }, [text_content, copy]); - - const handleDownloadImage = useCallback(() => { - if (!image_content?.image_url) return; - downloadImage( - image_content.image_url, - selectedProduct?.product_name || text_content?.headline || 'Your Product', - text_content?.tagline, - ); - }, [image_content, selectedProduct, text_content]); - - // Get product display name - const productDisplayName = useMemo(() => { - if (selectedProduct) { - return selectedProduct.product_name; - } - return text_content?.headline || 'Your Content'; - }, [selectedProduct, text_content?.headline]); - - return ( -
- {/* Selection confirmation */} - {selectedProduct && ( - - You selected "{selectedProduct.product_name}". Here's what I've created – let me know if you need anything changed. - - )} - - {/* Sparkle Headline - Figma style */} - {text_content?.headline && ( - - ✨ Discover the serene elegance of {productDisplayName}. - - )} - - {/* Body Copy */} - {text_content?.body && ( - - {text_content.body} - - )} - - {/* Hashtags */} - {text_content?.tagline && ( - - {text_content.tagline} - - )} - - {/* Error Banner */} - {(error || text_error) && !violations.some(v => v.message.toLowerCase().includes('filter')) && ( -
- -
- - {getErrorMessage(error || text_error).title} - - - {getErrorMessage(error || text_error).description} - -
-
- )} - - {/* Image Preview */} - {imageGenerationEnabled && image_content?.image_url && ( - - )} - - {/* Image Error State */} - {imageGenerationEnabled && !image_content?.image_url && (image_error || error) && ( -
- - - {getErrorMessage(image_error || error).title} - - - Click Regenerate to try again - -
- )} - - - - {/* Compliance + Footer + Accordion */} - -
- ); -}); -InlineContentPreview.displayName = 'InlineContentPreview'; diff --git a/src/App/src/components/MessageBubble.tsx b/src/App/src/components/MessageBubble.tsx deleted file mode 100644 index d4509a0c5..000000000 --- a/src/App/src/components/MessageBubble.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { memo, useCallback } 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 { useCopyToClipboard } from '../hooks'; -import { AI_DISCLAIMER } from '../utils'; - -export interface MessageBubbleProps { - message: ChatMessage; -} - -/** - * Renders a single chat message — user or assistant. - * - * - User messages: right-aligned, brand-coloured bubble. - * - Assistant messages: left-aligned, full-width, with optional agent badge, - * markdown rendering, copy button and AI disclaimer. - */ -export const MessageBubble = memo(function MessageBubble({ message }: MessageBubbleProps) { - const isUser = message.role === 'user'; - const { copied, copy } = useCopyToClipboard(); - const handleCopy = useCallback(() => copy(message.content), [copy, message.content]); - - 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 deleted file mode 100644 index 873ae6620..000000000 --- a/src/App/src/components/ProductCard.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { memo } from 'react'; -import { - Text, - tokens, -} from '@fluentui/react-components'; -import { Box20Regular } from '@fluentui/react-icons'; -import type { Product } from '../types'; - -export interface ProductCardProps { - product: Product; - /** Visual size variant — "normal" for product review grid, "compact" for selected-product view. */ - size?: 'normal' | 'compact'; - /** Whether the card is currently selected (shows brand border). */ - isSelected?: boolean; - /** Click handler. Omit for read-only cards. */ - onClick?: () => void; - disabled?: boolean; -} - -/** - * Reusable product card with image/placeholder, name, tags and price. - * Used by both ProductReview (selectable) and SelectedProductView (read-only). - */ -export const ProductCard = memo(function ProductCard({ - product, - size = 'normal', - isSelected = false, - onClick, - disabled = false, -}: ProductCardProps) { - const isCompact = size === 'compact'; - const imgSize = isCompact ? 56 : 80; - const isInteractive = !!onClick && !disabled; - - return ( -
- {/* Image or placeholder */} - {product.image_url ? ( - {product.product_name} - ) : ( -
- -
- )} - - {/* Product info */} -
- - {product.product_name} - - - {product.tags || product.description || 'soft white, airy, minimal, fresh'} - - - ${product.price?.toFixed(2) || '59.95'} USD - -
-
- ); -}); -ProductCard.displayName = 'ProductCard'; diff --git a/src/App/src/components/ProductReview.tsx b/src/App/src/components/ProductReview.tsx deleted file mode 100644 index a7fa7e9d3..000000000 --- a/src/App/src/components/ProductReview.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { memo, useMemo, useCallback } 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'; -import { ProductCard } from './ProductCard'; - -interface ProductReviewProps { - products: Product[]; - onConfirm: () => void; - isAwaitingResponse?: boolean; - availableProducts?: Product[]; - onProductSelect?: (product: Product) => void; - disabled?: boolean; -} - -export const ProductReview = memo(function ProductReview({ - products, - onConfirm, - isAwaitingResponse = false, - availableProducts = [], - onProductSelect, - disabled = false, -}: ProductReviewProps) { - const displayProducts = useMemo( - () => availableProducts.length > 0 ? availableProducts : products, - [availableProducts, products], - ); - const selectedProductIds = useMemo( - () => new Set(products.map(p => p.sku || p.product_name)), - [products], - ); - - const isProductSelected = useCallback((product: Product): boolean => { - return selectedProductIds.has(product.sku || product.product_name); - }, [selectedProductIds]); - - const handleProductClick = useCallback((product: Product) => { - if (onProductSelect) { - onProductSelect(product); - } - }, [onProductSelect]); - - 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_DISCLAIMER} - -
-
- ); -}); -ProductReview.displayName = 'ProductReview'; diff --git a/src/App/src/components/SelectedProductView.tsx b/src/App/src/components/SelectedProductView.tsx deleted file mode 100644 index 743a5f86f..000000000 --- a/src/App/src/components/SelectedProductView.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { memo } 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 = 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/SuggestionCard.tsx b/src/App/src/components/SuggestionCard.tsx deleted file mode 100644 index 55e8e1570..000000000 --- a/src/App/src/components/SuggestionCard.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { memo } from 'react'; -import { - Card, - Text, - tokens, -} from '@fluentui/react-components'; - -export interface SuggestionCardProps { - title: string; - icon: string; - isSelected?: boolean; - onClick: () => void; -} - -/** - * A single suggestion prompt card shown on the WelcomeCard screen. - * Handles its own hover / selected styling. - */ -export const SuggestionCard = memo(function SuggestionCard({ - title, - icon, - isSelected = false, - onClick, -}: SuggestionCardProps) { - return ( - { - if (!isSelected) { - e.currentTarget.style.backgroundColor = tokens.colorBrandBackground2; - } - }} - onMouseLeave={(e) => { - if (!isSelected) { - e.currentTarget.style.backgroundColor = tokens.colorNeutralBackground1; - } - }} - > -
-
- Prompt icon -
-
- - {title} - -
-
-
- ); -}); -SuggestionCard.displayName = 'SuggestionCard'; diff --git a/src/App/src/components/TypingIndicator.tsx b/src/App/src/components/TypingIndicator.tsx deleted file mode 100644 index 36fdbbf89..000000000 --- a/src/App/src/components/TypingIndicator.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { memo, useMemo } from 'react'; -import { - Button, - Text, - Tooltip, - tokens, -} from '@fluentui/react-components'; -import { Stop24Regular } from '@fluentui/react-icons'; - -export interface TypingIndicatorProps { - /** Status text shown next to the dots (e.g. "Generating image…"). Falls back to "Thinking…". */ - statusText?: string; - /** Callback wired to the Stop button. If omitted the button is hidden. */ - onStop?: () => void; -} - -/** - * Animated "thinking" indicator with optional status text and a Stop button. - */ -export const TypingIndicator = memo(function TypingIndicator({ statusText, onStop }: TypingIndicatorProps) { - const dotStyle = useMemo(() => (delay: string): React.CSSProperties => ({ - width: '8px', - height: '8px', - borderRadius: '50%', - backgroundColor: tokens.colorBrandBackground, - animation: 'pulse 1.4s infinite ease-in-out', - animationDelay: delay, - }), []); - - return ( -
-
- - - - - -
- - - {statusText || 'Thinking...'} - - - {onStop && ( - - - - )} -
- ); -}); -TypingIndicator.displayName = 'TypingIndicator'; diff --git a/src/App/src/components/ViolationCard.tsx b/src/App/src/components/ViolationCard.tsx deleted file mode 100644 index 479bbbfd6..000000000 --- a/src/App/src/components/ViolationCard.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { memo, useMemo } from 'react'; -import { - Text, -} from '@fluentui/react-components'; -import { - ErrorCircle20Regular, - Warning20Regular, - Info20Regular, -} from '@fluentui/react-icons'; -import type { ComplianceViolation } from '../types'; - -export interface ViolationCardProps { - violation: ComplianceViolation; -} - -/** - * A single compliance violation row with severity-coloured icon and background. - */ -export const ViolationCard = memo(function ViolationCard({ violation }: ViolationCardProps) { - const { icon, bg } = useMemo(() => { - switch (violation.severity) { - case 'error': - return { - icon: , - bg: '#fde7e9', - }; - case 'warning': - return { - icon: , - bg: '#fff4ce', - }; - case 'info': - return { - icon: , - bg: '#deecf9', - }; - } - }, [violation.severity]); - - return ( -
- {icon} -
- - {violation.message} - - - {violation.suggestion} - -
-
- ); -}); -ViolationCard.displayName = 'ViolationCard'; diff --git a/src/App/src/components/WelcomeCard.tsx b/src/App/src/components/WelcomeCard.tsx deleted file mode 100644 index b56b781c4..000000000 --- a/src/App/src/components/WelcomeCard.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { memo, useMemo } from 'react'; -import { - Text, - tokens, -} from '@fluentui/react-components'; -import { SuggestionCard } from './SuggestionCard'; -import FirstPromptIcon from '../styles/images/firstprompt.png'; -import SecondPromptIcon from '../styles/images/secondprompt.png'; - -interface SuggestionData { - title: string; - icon: string; -} - -const suggestions: SuggestionData[] = [ - { - 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.", - 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.", - icon: SecondPromptIcon, - } -]; - -interface WelcomeCardProps { - onSuggestionClick: (prompt: string) => void; - currentInput?: string; -} - -export const WelcomeCard = memo(function WelcomeCard({ onSuggestionClick, currentInput = '' }: WelcomeCardProps) { - const selectedIndex = useMemo( - () => suggestions.findIndex(s => s.title === currentInput), - [currentInput], - ); - - return ( -
- {/* Welcome card with suggestions inside */} -
- {/* Header with icon and welcome message */} -
- - Welcome to your Content Generation Accelerator - - - Here are the options I can assist you with today - -
- - {/* Suggestion cards - vertical layout */} -
- {suggestions.map((suggestion, index) => { - const isSelected = index === selectedIndex; - return ( - onSuggestionClick(suggestion.title)} - /> - ); - })} -
-
-
- ); -}); -WelcomeCard.displayName = 'WelcomeCard'; diff --git a/src/App/src/hooks/index.ts b/src/App/src/hooks/index.ts deleted file mode 100644 index f23f96430..000000000 --- a/src/App/src/hooks/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Barrel export for all custom hooks. - * Import hooks from '../hooks' instead of individual files. - */ -export { useAutoScroll } from './useAutoScroll'; -export { useChatOrchestrator } from './useChatOrchestrator'; -export { useContentGeneration } from './useContentGeneration'; -export { useConversationActions } from './useConversationActions'; -export { useCopyToClipboard } from './useCopyToClipboard'; -export { useWindowSize } from './useWindowSize'; diff --git a/src/App/src/hooks/useAutoScroll.ts b/src/App/src/hooks/useAutoScroll.ts deleted file mode 100644 index 8b1a2a9d1..000000000 --- a/src/App/src/hooks/useAutoScroll.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useRef } from 'react'; - -/** - * Scrolls a sentinel element into view whenever any dependency changes. - * - * @param deps - React dependency list that triggers the scroll. - * @returns A ref to attach to a zero-height element at the bottom of the - * scrollable container (the "scroll anchor"). - * - * @example - * ```tsx - * const endRef = useAutoScroll([messages, isLoading]); - * return ( - *
- * {messages.map(m => )} - *
- *
- * ); - * ``` - */ -export function useAutoScroll(deps: React.DependencyList) { - const endRef = useRef(null); - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { - endRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, deps); - - return endRef; -} diff --git a/src/App/src/hooks/useChatOrchestrator.ts b/src/App/src/hooks/useChatOrchestrator.ts deleted file mode 100644 index f4edc088c..000000000 --- a/src/App/src/hooks/useChatOrchestrator.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { useCallback, type MutableRefObject } from 'react'; - -import type { AgentResponse, GeneratedContent } from '../types'; -import { createMessage, createErrorMessage, matchesAnyKeyword, createNameSwapper } from '../utils'; -import { - useAppDispatch, - useAppSelector, - selectConversationId, - selectUserId, - selectPendingBrief, - selectConfirmedBrief, - selectAwaitingClarification, - selectSelectedProducts, - selectAvailableProducts, - selectGeneratedContent, - addMessage, - setIsLoading, - setGenerationStatus, - GenerationStatus, - setPendingBrief, - setConfirmedBrief, - setAwaitingClarification, - setSelectedProducts, - setGeneratedContent, - incrementHistoryRefresh, - selectConversationTitle, - setConversationTitle, -} from '../store'; -import type { AppDispatch } from '../store'; - -/* ------------------------------------------------------------------ */ -/* Shared helper — consumes a streamChat generator and dispatches */ -/* the final assistant message. Used by branches 1-b, 3-b, 4-b. */ -/* ------------------------------------------------------------------ */ - -async function consumeStreamChat( - stream: AsyncGenerator, - dispatch: AppDispatch, -): Promise { - let fullContent = ''; - let currentAgent = ''; - let messageAdded = false; - - for await (const response of stream) { - if (response.type === 'agent_response') { - fullContent = response.content; - currentAgent = response.agent || ''; - if ((response.is_final || response.requires_user_input) && !messageAdded) { - dispatch(addMessage(createMessage('assistant', fullContent, currentAgent))); - messageAdded = true; - } - } else if (response.type === 'error') { - dispatch( - addMessage( - createMessage( - 'assistant', - response.content || 'An error occurred while processing your request.', - ), - ), - ); - messageAdded = true; - } - } -} - -/* ------------------------------------------------------------------ */ -/* Hook */ -/* ------------------------------------------------------------------ */ - -/** - * Orchestrates the entire "send a message" flow. - * - * Depending on the current conversation phase it will: - * - refine a pending brief (PlanningAgent) - * - answer a general question while a brief is pending (streamChat) - * - forward a product-selection request (ProductAgent) - * - regenerate an image (ImageAgent) - * - parse a new creative brief (PlanningAgent) - * - fall through to generic chat (streamChat) - * - * All Redux reads/writes happen inside the hook so the caller is kept - * thin and declarative. - * - * @param abortControllerRef Shared ref that lets the parent (or sibling - * hooks) cancel the in-flight request. - * @returns `{ sendMessage }` — the callback to wire into `ChatPanel`. - */ -export function useChatOrchestrator( - abortControllerRef: MutableRefObject, -) { - const dispatch = useAppDispatch(); - const conversationId = useAppSelector(selectConversationId); - const userId = useAppSelector(selectUserId); - const pendingBrief = useAppSelector(selectPendingBrief); - const confirmedBrief = useAppSelector(selectConfirmedBrief); - const awaitingClarification = useAppSelector(selectAwaitingClarification); - const selectedProducts = useAppSelector(selectSelectedProducts); - const availableProducts = useAppSelector(selectAvailableProducts); - const generatedContent = useAppSelector(selectGeneratedContent); - const conversationTitle = useAppSelector(selectConversationTitle); - - const sendMessage = useCallback( - async (content: string) => { - dispatch(addMessage(createMessage('user', content))); - dispatch(setIsLoading(true)); - - // Create new abort controller for this request - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - // Dynamic imports to keep the initial bundle lean - const { streamChat, parseBrief, selectProducts } = await import( - '../api' - ); - - /* ---------------------------------------------------------- */ - /* Branch 1 – pending brief, not yet confirmed */ - /* ---------------------------------------------------------- */ - if (pendingBrief && !confirmedBrief) { - const refinementKeywords = [ - 'change', 'update', 'modify', 'add', 'remove', 'delete', - 'set', 'make', 'should be', - ] as const; - const isRefinement = matchesAnyKeyword(content, refinementKeywords); - - if (isRefinement || awaitingClarification) { - // --- 1-a Refine the brief -------------------------------- - const refinementPrompt = `Current creative brief:\n${JSON.stringify(pendingBrief, null, 2)}\n\nUser requested change: ${content}\n\nPlease update the brief accordingly and return the complete updated brief.`; - - dispatch(setGenerationStatus(GenerationStatus.UPDATING_BRIEF)); - const parsed = await parseBrief( - refinementPrompt, - conversationId, - userId, - signal, - ); - - if (parsed.generated_title && !conversationTitle) { - dispatch(setConversationTitle(parsed.generated_title)); - } - - if (parsed.brief) { - dispatch(setPendingBrief(parsed.brief)); - } - - if (parsed.requires_clarification && parsed.clarifying_questions) { - dispatch(setAwaitingClarification(true)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage('assistant', parsed.clarifying_questions, 'PlanningAgent'), - ), - ); - } else { - dispatch(setAwaitingClarification(false)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage( - 'assistant', - "I've updated the brief based on your feedback. Please review the changes above. Let me know if you'd like any other modifications, or click **Confirm Brief** when you're satisfied.", - 'PlanningAgent', - ), - ), - ); - } - } else { - // --- 1-b General question while brief is pending ----------- - dispatch(setGenerationStatus(GenerationStatus.PROCESSING_QUESTION)); - await consumeStreamChat( - streamChat(content, conversationId, userId, signal), - dispatch, - ); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - } - - /* ---------------------------------------------------------- */ - /* Branch 2 – brief confirmed, in product selection */ - /* ---------------------------------------------------------- */ - } else if (confirmedBrief && !generatedContent) { - dispatch(setGenerationStatus(GenerationStatus.FINDING_PRODUCTS)); - const result = await selectProducts( - content, - selectedProducts, - conversationId, - userId, - signal, - ); - dispatch(setSelectedProducts(result.products || [])); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage( - 'assistant', - result.message || 'Products updated.', - 'ProductAgent', - ), - ), - ); - - /* ---------------------------------------------------------- */ - /* Branch 3 – content generated, post-generation phase */ - /* ---------------------------------------------------------- */ - } else if (generatedContent && confirmedBrief) { - const imageModificationKeywords = [ - 'change', 'modify', 'update', 'replace', 'show', 'display', - 'use', 'instead', 'different', 'another', 'make it', 'make the', - 'kitchen', 'dining', 'living', 'bedroom', 'bathroom', 'outdoor', - 'office', 'room', 'scene', 'setting', 'background', 'style', - 'color', 'lighting', - ] as const; - const isImageModification = matchesAnyKeyword(content, imageModificationKeywords); - - if (isImageModification) { - // --- 3-a Regenerate image -------------------------------- - const { streamRegenerateImage } = await import('../api'); - dispatch( - setGenerationStatus(GenerationStatus.REGENERATING_IMAGE), - ); - - let responseData: GeneratedContent | null = null; - let messageContent = ''; - - // Detect if user mentions a different product - const mentionedProduct = availableProducts.find((p) => - content.toLowerCase().includes(p.product_name.toLowerCase()), - ); - const productsForRequest = mentionedProduct - ? [mentionedProduct] - : selectedProducts; - - const previousPrompt = - generatedContent.image_content?.prompt_used; - - for await (const response of streamRegenerateImage( - content, - confirmedBrief, - productsForRequest, - previousPrompt, - conversationId, - userId, - signal, - )) { - if (response.type === 'heartbeat') { - dispatch( - setGenerationStatus({ - status: GenerationStatus.POLLING, - label: response.message || 'Regenerating image...', - }), - ); - } else if ( - response.type === 'agent_response' && - response.is_final - ) { - try { - const parsedContent = JSON.parse(response.content); - - if ( - parsedContent.image_url || - parsedContent.image_base64 - ) { - // Replace old product name in text_content when switching - const swapName = createNameSwapper( - selectedProducts[0]?.product_name, - mentionedProduct?.product_name, - ); - const tc = generatedContent.text_content; - - responseData = { - ...generatedContent, - text_content: mentionedProduct - ? { - ...tc, - headline: swapName?.(tc?.headline) ?? tc?.headline, - body: swapName?.(tc?.body) ?? tc?.body, - tagline: swapName?.(tc?.tagline) ?? tc?.tagline, - cta_text: swapName?.(tc?.cta_text) ?? tc?.cta_text, - } - : tc, - image_content: { - ...generatedContent.image_content, - image_url: - parsedContent.image_url || - generatedContent.image_content?.image_url, - image_base64: parsedContent.image_base64, - prompt_used: - parsedContent.image_prompt || - generatedContent.image_content?.prompt_used, - }, - }; - dispatch(setGeneratedContent(responseData)); - - if (mentionedProduct) { - dispatch(setSelectedProducts([mentionedProduct])); - } - - // Update confirmed brief to include the modification - const updatedBrief = { - ...confirmedBrief, - visual_guidelines: `${confirmedBrief.visual_guidelines}. User modification: ${content}`, - }; - dispatch(setConfirmedBrief(updatedBrief)); - - messageContent = - parsedContent.message || - 'Image regenerated with your requested changes.'; - } else if (parsedContent.error) { - messageContent = parsedContent.error; - } else { - messageContent = - parsedContent.message || 'I processed your request.'; - } - } catch { - messageContent = - response.content || 'Image regenerated.'; - } - } else if (response.type === 'error') { - messageContent = - response.content || - 'An error occurred while regenerating the image.'; - } - } - - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage(createMessage('assistant', messageContent, 'ImageAgent')), - ); - } else { - // --- 3-b General question after content generation -------- - dispatch(setGenerationStatus(GenerationStatus.PROCESSING_REQUEST)); - await consumeStreamChat( - streamChat(content, conversationId, userId, signal), - dispatch, - ); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - } - - /* ---------------------------------------------------------- */ - /* Branch 4 – default: initial flow */ - /* ---------------------------------------------------------- */ - } else { - const briefKeywords = [ - 'campaign', 'marketing', 'target audience', 'objective', - 'deliverable', - ] as const; - const isBriefLike = matchesAnyKeyword(content, briefKeywords); - - if (isBriefLike && !confirmedBrief) { - // --- 4-a Parse as creative brief -------------------------- - dispatch(setGenerationStatus(GenerationStatus.ANALYZING_BRIEF)); - const parsed = await parseBrief( - content, - conversationId, - userId, - signal, - ); - - if (parsed.generated_title && !conversationTitle) { - dispatch(setConversationTitle(parsed.generated_title)); - } - - if (parsed.rai_blocked) { - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage('assistant', parsed.message, 'ContentSafety'), - ), - ); - } else if ( - parsed.requires_clarification && - parsed.clarifying_questions - ) { - if (parsed.brief) { - dispatch(setPendingBrief(parsed.brief)); - } - dispatch(setAwaitingClarification(true)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage( - 'assistant', - parsed.clarifying_questions, - 'PlanningAgent', - ), - ), - ); - } else { - if (parsed.brief) { - dispatch(setPendingBrief(parsed.brief)); - } - dispatch(setAwaitingClarification(false)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch( - addMessage( - createMessage( - 'assistant', - "I've parsed your creative brief. Please review the details below and let me know if you'd like to make any changes. You can say things like \"change the target audience to...\" or \"add a call to action...\". When everything looks good, click **Confirm Brief** to proceed.", - 'PlanningAgent', - ), - ), - ); - } - } else { - // --- 4-b Generic chat ----------------------------------- - dispatch(setGenerationStatus(GenerationStatus.PROCESSING_REQUEST)); - await consumeStreamChat( - streamChat(content, conversationId, userId, signal), - dispatch, - ); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - } - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - dispatch(addMessage(createMessage('assistant', 'Generation stopped.'))); - } else { - dispatch( - addMessage( - createErrorMessage( - 'Sorry, there was an error processing your request. Please try again.', - ), - ), - ); - } - } finally { - dispatch(setIsLoading(false)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - abortControllerRef.current = null; - dispatch(incrementHistoryRefresh()); - } - }, - [ - conversationId, - userId, - confirmedBrief, - pendingBrief, - selectedProducts, - generatedContent, - availableProducts, - dispatch, - awaitingClarification, - conversationTitle, - abortControllerRef, - ], - ); - - return { sendMessage }; -} diff --git a/src/App/src/hooks/useContentGeneration.ts b/src/App/src/hooks/useContentGeneration.ts deleted file mode 100644 index 895c20025..000000000 --- a/src/App/src/hooks/useContentGeneration.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useCallback, type MutableRefObject } from 'react'; - -import { createMessage, createErrorMessage, buildGeneratedContent } from '../utils'; -import { - useAppDispatch, - useAppSelector, - selectConfirmedBrief, - selectSelectedProducts, - selectConversationId, - selectUserId, - addMessage, - setIsLoading, - setGenerationStatus, - GenerationStatus, - setGeneratedContent, -} from '../store'; - -/** - * Handles the full content-generation lifecycle (start → poll → result) - * and exposes a way to abort the in-flight request. - * - * @param abortControllerRef Shared ref so the UI can cancel either - * chat-orchestration **or** content-generation with one button. - */ -export function useContentGeneration( - abortControllerRef: MutableRefObject, -) { - const dispatch = useAppDispatch(); - const confirmedBrief = useAppSelector(selectConfirmedBrief); - const selectedProducts = useAppSelector(selectSelectedProducts); - const conversationId = useAppSelector(selectConversationId); - const userId = useAppSelector(selectUserId); - - /** Kick off polling-based content generation. */ - const generateContent = useCallback(async () => { - if (!confirmedBrief) return; - - dispatch(setIsLoading(true)); - dispatch(setGenerationStatus(GenerationStatus.STARTING_GENERATION)); - - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - const { streamGenerateContent } = await import('../api'); - - for await (const response of streamGenerateContent( - confirmedBrief, - selectedProducts, - true, - conversationId, - userId, - signal, - )) { - // Heartbeat → update the status bar - if (response.type === 'heartbeat') { - const statusMessage = response.content || 'Generating content...'; - const elapsed = (response as { elapsed?: number }).elapsed || 0; - dispatch(setGenerationStatus({ - status: GenerationStatus.POLLING, - label: `${statusMessage} (${elapsed}s)`, - })); - continue; - } - - if (response.is_final && response.type !== 'error') { - dispatch(setGenerationStatus(GenerationStatus.PROCESSING_RESULTS)); - try { - const rawContent = JSON.parse(response.content); - const genContent = buildGeneratedContent(rawContent); - dispatch(setGeneratedContent(genContent)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - } catch { - // Content parse failure — non-critical, generation result may be malformed - } - } else if (response.type === 'error') { - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - dispatch(addMessage(createErrorMessage( - `Error generating content: ${response.content}`, - ))); - } - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - dispatch(addMessage(createMessage('assistant', 'Content generation stopped.'))); - } else { - dispatch(addMessage(createErrorMessage( - 'Sorry, there was an error generating content. Please try again.', - ))); - } - } finally { - dispatch(setIsLoading(false)); - dispatch(setGenerationStatus(GenerationStatus.IDLE)); - abortControllerRef.current = null; - } - }, [ - confirmedBrief, - selectedProducts, - conversationId, - dispatch, - userId, - abortControllerRef, - ]); - - /** Abort whichever request is currently in-flight. */ - const stopGeneration = useCallback(() => { - abortControllerRef.current?.abort(); - }, [abortControllerRef]); - - return { generateContent, stopGeneration }; -} diff --git a/src/App/src/hooks/useConversationActions.ts b/src/App/src/hooks/useConversationActions.ts deleted file mode 100644 index 5dd8e87b9..000000000 --- a/src/App/src/hooks/useConversationActions.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { useCallback } from 'react'; - -import type { ChatMessage, Product, CreativeBrief } from '../types'; -import { createMessage, buildGeneratedContent } from '../utils'; -import { httpClient } from '../api'; -import { - useAppDispatch, - useAppSelector, - selectUserId, - selectConversationId, - selectPendingBrief, - selectSelectedProducts, - resetChat, - resetContent, - setConversationId, - setConversationTitle, - setMessages, - addMessage, - setPendingBrief, - setConfirmedBrief, - setAwaitingClarification, - setSelectedProducts, - setAvailableProducts, - setGeneratedContent, - toggleChatHistory, -} from '../store'; - -/* ------------------------------------------------------------------ */ -/* Hook */ -/* ------------------------------------------------------------------ */ - -/** - * Encapsulates every conversation-level user action: - * - * - Loading a saved conversation from history - * - Starting a brand-new conversation - * - Confirming / cancelling a creative brief - * - Starting over with products - * - Toggling a product selection - * - Toggling the chat-history sidebar - * - * All Redux reads/writes are internal so the consumer stays declarative. - */ -export function useConversationActions() { - const dispatch = useAppDispatch(); - const userId = useAppSelector(selectUserId); - const conversationId = useAppSelector(selectConversationId); - const pendingBrief = useAppSelector(selectPendingBrief); - const selectedProducts = useAppSelector(selectSelectedProducts); - - /* ------------------------------------------------------------ */ - /* Select (load) a conversation from history */ - /* ------------------------------------------------------------ */ - const selectConversation = useCallback( - async (selectedConversationId: string) => { - try { - const data = await httpClient.get<{ - messages?: { - role: string; - content: string; - timestamp?: string; - agent?: string; - }[]; - brief?: unknown; - generated_content?: Record; - }>(`/conversations/${selectedConversationId}`, { - params: { user_id: userId }, - }); - - dispatch(setConversationId(selectedConversationId)); - dispatch(setConversationTitle(null)); // Will use title from conversation list - - const loadedMessages: ChatMessage[] = (data.messages || []).map( - (m, index) => ({ - id: `${selectedConversationId}-${index}`, - role: m.role as 'user' | 'assistant', - content: m.content, - timestamp: m.timestamp || new Date().toISOString(), - agent: m.agent, - }), - ); - dispatch(setMessages(loadedMessages)); - dispatch(setPendingBrief(null)); - dispatch(setAwaitingClarification(false)); - dispatch( - setConfirmedBrief( - (data.brief as CreativeBrief) || null, - ), - ); - - // Restore availableProducts so product/color name detection works - // when regenerating images in a restored conversation - if (data.brief) { - try { - const productsData = await httpClient.get<{ - products?: Product[]; - }>('/products'); - dispatch(setAvailableProducts(productsData.products || [])); - } catch { - // Non-critical — product load failure for restored conversation - } - } - - if (data.generated_content) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const gc = data.generated_content as any; - const restoredContent = buildGeneratedContent(gc, true); - dispatch(setGeneratedContent(restoredContent)); - - if ( - gc.selected_products && - Array.isArray(gc.selected_products) - ) { - dispatch(setSelectedProducts(gc.selected_products)); - } else { - dispatch(setSelectedProducts([])); - } - } else { - dispatch(setGeneratedContent(null)); - dispatch(setSelectedProducts([])); - } - } catch { - // Error loading conversation — swallowed silently - } - }, - [userId, dispatch], - ); - - /* ------------------------------------------------------------ */ - /* Start a new conversation */ - /* ------------------------------------------------------------ */ - const newConversation = useCallback(() => { - dispatch(resetChat(undefined)); - dispatch(resetContent()); - }, [dispatch]); - - /* ------------------------------------------------------------ */ - /* Brief lifecycle */ - /* ------------------------------------------------------------ */ - const confirmBrief = useCallback(async () => { - if (!pendingBrief) return; - - try { - const { confirmBrief: confirmBriefApi } = await import('../api'); - await confirmBriefApi(pendingBrief, conversationId, userId); - dispatch(setConfirmedBrief(pendingBrief)); - dispatch(setPendingBrief(null)); - dispatch(setAwaitingClarification(false)); - - const productsData = await httpClient.get<{ products?: Product[] }>( - '/products', - ); - dispatch(setAvailableProducts(productsData.products || [])); - - dispatch( - addMessage( - createMessage( - 'assistant', - "Great! Your creative brief has been confirmed. Here are the available products for your campaign. Select the ones you'd like to feature, or tell me what you're looking for.", - 'ProductAgent', - ), - ), - ); - } catch { - // Error confirming brief — swallowed silently - } - }, [conversationId, userId, pendingBrief, dispatch]); - - const cancelBrief = useCallback(() => { - dispatch(setPendingBrief(null)); - dispatch(setAwaitingClarification(false)); - dispatch( - addMessage( - createMessage( - 'assistant', - 'No problem. Please provide your creative brief again or ask me any questions.', - ), - ), - ); - }, [dispatch]); - - /* ------------------------------------------------------------ */ - /* Product actions */ - /* ------------------------------------------------------------ */ - const selectProduct = 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 — replace any existing selection - dispatch(setSelectedProducts([product])); - } - }, - [selectedProducts, dispatch], - ); - - /* ------------------------------------------------------------ */ - /* Sidebar toggle */ - /* ------------------------------------------------------------ */ - const toggleHistory = useCallback(() => { - dispatch(toggleChatHistory()); - }, [dispatch]); - - return { - selectConversation, - newConversation, - confirmBrief, - cancelBrief, - selectProduct, - toggleHistory, - }; -} diff --git a/src/App/src/hooks/useCopyToClipboard.ts b/src/App/src/hooks/useCopyToClipboard.ts deleted file mode 100644 index 5887c9532..000000000 --- a/src/App/src/hooks/useCopyToClipboard.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; - -/** - * Copy text to the clipboard and expose a transient `copied` flag. - * - * @param resetTimeout - Milliseconds before `copied` resets to `false` (default 2 000). - * @returns `{ copied, copy }` — `copy(text)` writes to the clipboard and - * flips `copied` to `true` for `resetTimeout` ms. - */ -export function useCopyToClipboard(resetTimeout = 2000) { - const [copied, setCopied] = useState(false); - const timerRef = useRef>(); - - const copy = useCallback( - (text: string) => { - navigator.clipboard.writeText(text).catch(() => { - // Clipboard write failure — non-critical - }); - setCopied(true); - clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setCopied(false), resetTimeout); - }, - [resetTimeout], - ); - - // Cleanup on unmount - useEffect(() => { - return () => clearTimeout(timerRef.current); - }, []); - - return { copied, copy }; -} diff --git a/src/App/src/hooks/useWindowSize.ts b/src/App/src/hooks/useWindowSize.ts deleted file mode 100644 index da662eb41..000000000 --- a/src/App/src/hooks/useWindowSize.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useState, useEffect } from 'react'; - -/** - * Returns the current window inner-width, updating on resize. - * Falls back to 1200 during SSR. - */ -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/src/store/appSlice.ts b/src/App/src/store/appSlice.ts deleted file mode 100644 index 952b6ae1e..000000000 --- a/src/App/src/store/appSlice.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * App slice — application-level state (user info, config, feature flags, UI toggles). - * createSlice + createAsyncThunk replaces manual dispatch + string constants. - * Granular selectors — each component subscribes only to the state it needs. - */ -import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; - -/* ------------------------------------------------------------------ */ -/* Generation-status enum */ -/* ------------------------------------------------------------------ */ - -/** - * Finite set of generation-status values. Components that read - * `generationStatus` can compare against these constants instead of - * relying on magic strings. - * - * `IDLE` means "no status to display". Every other member maps to a - * user-facing label via {@link GENERATION_STATUS_LABELS}. - */ -export enum GenerationStatus { - IDLE = '', - UPDATING_BRIEF = 'UPDATING_BRIEF', - PROCESSING_QUESTION = 'PROCESSING_QUESTION', - FINDING_PRODUCTS = 'FINDING_PRODUCTS', - REGENERATING_IMAGE = 'REGENERATING_IMAGE', - PROCESSING_REQUEST = 'PROCESSING_REQUEST', - ANALYZING_BRIEF = 'ANALYZING_BRIEF', - STARTING_GENERATION = 'STARTING_GENERATION', - PROCESSING_RESULTS = 'PROCESSING_RESULTS', - /** Used for heartbeat polling where the label is dynamic. */ - POLLING = 'POLLING', -} - -/** Display strings shown in the UI for each status. */ -const GENERATION_STATUS_LABELS: Record = { - [GenerationStatus.IDLE]: '', - [GenerationStatus.UPDATING_BRIEF]: 'Updating creative brief...', - [GenerationStatus.PROCESSING_QUESTION]: 'Processing your question...', - [GenerationStatus.FINDING_PRODUCTS]: 'Finding products...', - [GenerationStatus.REGENERATING_IMAGE]: 'Regenerating image with your changes...', - [GenerationStatus.PROCESSING_REQUEST]: 'Processing your request...', - [GenerationStatus.ANALYZING_BRIEF]: 'Analyzing creative brief...', - [GenerationStatus.STARTING_GENERATION]: 'Starting content generation...', - [GenerationStatus.PROCESSING_RESULTS]: 'Processing results...', - [GenerationStatus.POLLING]: 'Generating content...', -}; - -/* ------------------------------------------------------------------ */ -/* Async Thunks */ -/* ------------------------------------------------------------------ */ - -export const fetchAppConfig = createAsyncThunk( - 'app/fetchAppConfig', - async () => { - const { getAppConfig } = await import('../api'); - const config = await getAppConfig(); - return config; - }, -); - -export const fetchUserInfo = createAsyncThunk( - 'app/fetchUserInfo', - async () => { - const { platformClient } = await import('../api/httpClient'); - const response = await platformClient.raw('/.auth/me'); - if (!response.ok) return { userId: 'anonymous', userName: '' }; - - const payload = await response.json(); - const claims: { typ: string; val: string }[] = payload[0]?.user_claims || []; - - const objectId = claims.find( - (c) => c.typ === 'http://schemas.microsoft.com/identity/claims/objectidentifier', - )?.val || 'anonymous'; - - const name = claims.find((c) => c.typ === 'name')?.val || ''; - - return { userId: objectId, userName: name }; - }, -); - -/* ------------------------------------------------------------------ */ -/* Slice */ -/* ------------------------------------------------------------------ */ - -interface AppState { - userId: string; - userName: string; - isLoading: boolean; - imageGenerationEnabled: boolean; - showChatHistory: boolean; - /** Current generation status enum value. */ - generationStatus: GenerationStatus; - /** Dynamic label override (used with GenerationStatus.POLLING). */ - generationStatusLabel: string; -} - -const initialState: AppState = { - userId: '', - userName: '', - isLoading: false, - imageGenerationEnabled: true, - showChatHistory: true, - generationStatus: GenerationStatus.IDLE, - generationStatusLabel: '', -}; - -const appSlice = createSlice({ - name: 'app', - initialState, - reducers: { - setIsLoading(state, action: PayloadAction) { - state.isLoading = action.payload; - }, - setGenerationStatus( - state, - action: PayloadAction, - ) { - if (typeof action.payload === 'string') { - state.generationStatus = action.payload; - state.generationStatusLabel = GENERATION_STATUS_LABELS[action.payload]; - } else { - state.generationStatus = action.payload.status; - state.generationStatusLabel = action.payload.label; - } - }, - 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) => { - state.imageGenerationEnabled = true; // default when fetch fails - }) - .addCase(fetchUserInfo.fulfilled, (state, action) => { - state.userId = action.payload.userId; - state.userName = action.payload.userName; - }) - .addCase(fetchUserInfo.rejected, (state) => { - state.userId = 'anonymous'; - state.userName = ''; - }); - }, -}); - -export const { setIsLoading, setGenerationStatus, toggleChatHistory } = - appSlice.actions; -export default appSlice.reducer; diff --git a/src/App/src/store/chatHistorySlice.ts b/src/App/src/store/chatHistorySlice.ts deleted file mode 100644 index b97b14e31..000000000 --- a/src/App/src/store/chatHistorySlice.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Chat history slice — conversation list CRUD via async thunks. - * createAsyncThunk replaces inline fetch + manual state updates in ChatHistory.tsx. - * Granular selectors for each piece of history state. - */ -import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; -import { httpClient } from '../api'; - -export interface ConversationSummary { - id: string; - title: string; - lastMessage: string; - timestamp: string; - messageCount: number; -} - -interface ChatHistoryState { - conversations: ConversationSummary[]; - isLoading: boolean; - error: string | null; - showAll: boolean; - isClearAllDialogOpen: boolean; - isClearing: boolean; -} - -const initialState: ChatHistoryState = { - conversations: [], - isLoading: true, - error: null, - showAll: false, - isClearAllDialogOpen: false, - isClearing: false, -}; - -/* ------------------------------------------------------------------ */ -/* Async Thunks */ -/* ------------------------------------------------------------------ */ - -export const fetchConversations = createAsyncThunk( - 'chatHistory/fetchConversations', - async () => { - const data = await httpClient.get<{ conversations?: ConversationSummary[] }>('/conversations'); - return (data.conversations || []) as ConversationSummary[]; - }, -); - -export const deleteConversation = createAsyncThunk( - 'chatHistory/deleteConversation', - async (conversationId: string) => { - await httpClient.delete(`/conversations/${conversationId}`); - return conversationId; - }, -); - -export const renameConversation = createAsyncThunk( - 'chatHistory/renameConversation', - async ({ conversationId, newTitle }: { conversationId: string; newTitle: string }) => { - await httpClient.put(`/conversations/${conversationId}`, { title: newTitle }); - return { conversationId, newTitle }; - }, -); - -export const clearAllConversations = createAsyncThunk( - 'chatHistory/clearAllConversations', - async () => { - await httpClient.delete('/conversations'); - }, -); - -/* ------------------------------------------------------------------ */ -/* Slice */ -/* ------------------------------------------------------------------ */ - -const chatHistorySlice = createSlice({ - name: 'chatHistory', - initialState, - reducers: { - setShowAll(state, action: PayloadAction) { - state.showAll = action.payload; - }, - setIsClearAllDialogOpen(state, action: PayloadAction) { - state.isClearAllDialogOpen = action.payload; - }, - }, - extraReducers: (builder) => { - builder - // Fetch - .addCase(fetchConversations.pending, (state) => { - state.isLoading = true; - state.error = null; - }) - .addCase(fetchConversations.fulfilled, (state, action) => { - state.conversations = action.payload; - state.isLoading = false; - }) - .addCase(fetchConversations.rejected, (state) => { - state.error = 'Unable to load conversation history'; - state.conversations = []; - state.isLoading = false; - }) - // Delete single - .addCase(deleteConversation.fulfilled, (state, action) => { - state.conversations = state.conversations.filter((c) => c.id !== action.payload); - }) - // Rename - .addCase(renameConversation.fulfilled, (state, action) => { - const conv = state.conversations.find((c) => c.id === action.payload.conversationId); - if (conv) conv.title = action.payload.newTitle; - }) - // Clear all - .addCase(clearAllConversations.pending, (state) => { - state.isClearing = true; - }) - .addCase(clearAllConversations.fulfilled, (state) => { - state.conversations = []; - state.isClearing = false; - state.isClearAllDialogOpen = false; - }) - .addCase(clearAllConversations.rejected, (state) => { - state.isClearing = false; - }); - }, -}); - -export const { setShowAll, setIsClearAllDialogOpen } = - chatHistorySlice.actions; -export default chatHistorySlice.reducer; diff --git a/src/App/src/store/chatSlice.ts b/src/App/src/store/chatSlice.ts deleted file mode 100644 index 71b25330e..000000000 --- a/src/App/src/store/chatSlice.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Chat slice — conversation state, messages, clarification flow. - * Typed createSlice replaces scattered useState-based state in App.tsx. - * Granular selectors for each piece of chat 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[]; - awaitingClarification: boolean; - historyRefreshTrigger: number; -} - -const initialState: ChatState = { - conversationId: uuidv4(), - conversationTitle: null, - messages: [], - awaitingClarification: false, - 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; - }, - setMessages(state, action: PayloadAction) { - state.messages = action.payload; - }, - addMessage(state, action: PayloadAction) { - state.messages.push(action.payload); - }, - setAwaitingClarification(state, action: PayloadAction) { - state.awaitingClarification = action.payload; - }, - incrementHistoryRefresh(state) { - state.historyRefreshTrigger += 1; - }, - /** Reset chat to a fresh conversation. Optionally provide a new ID. */ - resetChat(state, action: PayloadAction) { - state.conversationId = action.payload ?? uuidv4(); - state.conversationTitle = null; - state.messages = []; - state.awaitingClarification = false; - }, - }, -}); - -export const { - setConversationId, - setConversationTitle, - setMessages, - addMessage, - setAwaitingClarification, - incrementHistoryRefresh, - resetChat, -} = chatSlice.actions; -export default chatSlice.reducer; diff --git a/src/App/src/store/contentSlice.ts b/src/App/src/store/contentSlice.ts deleted file mode 100644 index 15736efd5..000000000 --- a/src/App/src/store/contentSlice.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Content slice — creative brief, product selection, generated content. - * Typed createSlice with granular selectors. - */ -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 deleted file mode 100644 index c9c663095..000000000 --- a/src/App/src/store/hooks.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Typed Redux hooks for type-safe store access throughout the app. - * Use useAppDispatch and useAppSelector instead of raw useDispatch/useSelector. - */ -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/index.ts b/src/App/src/store/index.ts deleted file mode 100644 index 1a7a98623..000000000 --- a/src/App/src/store/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Barrel export for the Redux store. - * Import everything you need from '../store'. - */ -export { store } from './store'; -export type { RootState, AppDispatch } from './store'; -export { useAppDispatch, useAppSelector } from './hooks'; - -// App slice – actions, thunks & enums -export { - fetchAppConfig, - fetchUserInfo, - setIsLoading, - setGenerationStatus, - toggleChatHistory, - GenerationStatus, -} from './appSlice'; - -// Chat slice – actions -export { - setConversationId, - setConversationTitle, - setMessages, - addMessage, - setAwaitingClarification, - incrementHistoryRefresh, - resetChat, -} from './chatSlice'; - -// Content slice – actions -export { - setPendingBrief, - setConfirmedBrief, - setSelectedProducts, - setAvailableProducts, - setGeneratedContent, - resetContent, -} from './contentSlice'; - -// Chat History slice – actions & thunks -export { - fetchConversations, - deleteConversation, - renameConversation, - clearAllConversations, - setShowAll, - setIsClearAllDialogOpen, -} from './chatHistorySlice'; -export type { ConversationSummary } from './chatHistorySlice'; - -// All selectors (centralized to avoid circular store ↔ slice imports) -export { - selectUserId, - selectUserName, - selectIsLoading, - selectGenerationStatusLabel, - selectImageGenerationEnabled, - selectShowChatHistory, - selectConversationId, - selectConversationTitle, - selectMessages, - selectAwaitingClarification, - selectHistoryRefreshTrigger, - selectPendingBrief, - selectConfirmedBrief, - selectSelectedProducts, - selectAvailableProducts, - selectGeneratedContent, - selectConversations, - selectIsHistoryLoading, - selectHistoryError, - selectShowAll, - selectIsClearAllDialogOpen, - selectIsClearing, -} from './selectors'; diff --git a/src/App/src/store/selectors.ts b/src/App/src/store/selectors.ts deleted file mode 100644 index a837844c5..000000000 --- a/src/App/src/store/selectors.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * All Redux selectors in one place. - * Importing RootState here (and ONLY here) avoids the circular dependency - * between store.ts ↔ slice files that confuses VS Code's TypeScript server. - */ -import type { RootState } from './store'; - -/* ---- App selectors ---- */ -export const selectUserId = (state: RootState) => state.app.userId; -export const selectUserName = (state: RootState) => state.app.userName; -export const selectIsLoading = (state: RootState) => state.app.isLoading; -export const selectGenerationStatusLabel = (state: RootState) => state.app.generationStatusLabel; -export const selectImageGenerationEnabled = (state: RootState) => state.app.imageGenerationEnabled; -export const selectShowChatHistory = (state: RootState) => state.app.showChatHistory; - -/* ---- Chat selectors ---- */ -export const selectConversationId = (state: RootState) => state.chat.conversationId; -export const selectConversationTitle = (state: RootState) => state.chat.conversationTitle; -export const selectMessages = (state: RootState) => state.chat.messages; -export const selectAwaitingClarification = (state: RootState) => state.chat.awaitingClarification; -export const selectHistoryRefreshTrigger = (state: RootState) => state.chat.historyRefreshTrigger; - -/* ---- Content selectors ---- */ -export const selectPendingBrief = (state: RootState) => state.content.pendingBrief; -export const selectConfirmedBrief = (state: RootState) => state.content.confirmedBrief; -export const selectSelectedProducts = (state: RootState) => state.content.selectedProducts; -export const selectAvailableProducts = (state: RootState) => state.content.availableProducts; -export const selectGeneratedContent = (state: RootState) => state.content.generatedContent; - -/* ---- Chat History selectors ---- */ -export const selectConversations = (state: RootState) => state.chatHistory.conversations; -export const selectIsHistoryLoading = (state: RootState) => state.chatHistory.isLoading; -export const selectHistoryError = (state: RootState) => state.chatHistory.error; -export const selectShowAll = (state: RootState) => state.chatHistory.showAll; -export const selectIsClearAllDialogOpen = (state: RootState) => state.chatHistory.isClearAllDialogOpen; -export const selectIsClearing = (state: RootState) => state.chatHistory.isClearing; diff --git a/src/App/src/store/store.ts b/src/App/src/store/store.ts deleted file mode 100644 index 81e515742..000000000 --- a/src/App/src/store/store.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Redux store — central state for the application. - * configureStore combines all domain-specific slices. - */ -import { configureStore } from '@reduxjs/toolkit'; -import appReducer from './appSlice'; -import chatReducer from './chatSlice'; -import contentReducer from './contentSlice'; -import chatHistoryReducer from './chatHistorySlice'; - -export const store = configureStore({ - reducer: { - app: appReducer, - chat: chatReducer, - content: contentReducer, - chatHistory: chatHistoryReducer, - }, -}); - -export type RootState = ReturnType; -export type AppDispatch = typeof store.dispatch; diff --git a/src/App/src/utils/briefFields.ts b/src/App/src/utils/briefFields.ts deleted file mode 100644 index beb44a88e..000000000 --- a/src/App/src/utils/briefFields.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Brief-field metadata shared between BriefReview and ConfirmedBriefView. - * - * Eliminates the duplicated field-label arrays. - */ -import type { CreativeBrief } from '../types'; - -/** - * Canonical map from `CreativeBrief` keys to user-friendly labels. - * Used by BriefReview (completeness gauges) and ConfirmedBriefView. - */ -export const BRIEF_FIELD_LABELS: Record = { - overview: 'Overview', - objectives: 'Objectives', - target_audience: 'Target Audience', - key_message: 'Key Message', - tone_and_style: 'Tone & Style', - deliverable: 'Deliverable', - timelines: 'Timelines', - visual_guidelines: 'Visual Guidelines', - cta: 'Call to Action', -}; - -/** - * Display order for brief fields in review UIs. - * - * The first element in each tuple is the `CreativeBrief` key, the second - * is the UI label (which may differ slightly from `BRIEF_FIELD_LABELS` - * for contextual reasons, e.g. "Campaign Objective" vs "Overview"). - */ -export const BRIEF_DISPLAY_ORDER: { 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' }, -]; - -/** - * The canonical list of all nine brief field keys, in display order. - */ -export const BRIEF_FIELD_KEYS: (keyof CreativeBrief)[] = BRIEF_DISPLAY_ORDER.map((f) => f.key); diff --git a/src/App/src/utils/contentErrors.ts b/src/App/src/utils/contentErrors.ts deleted file mode 100644 index dc9ca497a..000000000 --- a/src/App/src/utils/contentErrors.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Detect whether an error message originates from a content-safety filter. - */ -export function 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()), - ); -} - -/** - * Return a user-friendly title/description for a generation 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.', - }; -} diff --git a/src/App/src/utils/contentParsing.ts b/src/App/src/utils/contentParsing.ts deleted file mode 100644 index e59ac85e3..000000000 --- a/src/App/src/utils/contentParsing.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Content parsing utilities — raw API response → typed domain objects. - * - * Centralizes the duplicated `textContent` string-to-object parsing, - * image URL resolution (blob rewriting, base64 fallback), and the - * `GeneratedContent` assembly that was copy-pasted across - * useContentGeneration, useConversationActions, and useChatOrchestrator. - */ -import type { GeneratedContent } from '../types'; - -/* ------------------------------------------------------------------ */ -/* Internal helpers (not exported — reduces public API surface) */ -/* ------------------------------------------------------------------ */ - -/** - * Rewrite Azure Blob Storage URLs to the application's proxy endpoint - * so the browser can fetch images without CORS issues. - */ -function rewriteBlobUrl(url: string): string { - if (!url.includes('blob.core.windows.net')) return url; - const parts = url.split('/'); - const filename = parts[parts.length - 1]; - const convId = parts[parts.length - 2]; - return `/api/images/${convId}/${filename}`; -} - -/* ------------------------------------------------------------------ */ -/* Parsing helpers (module-internal — not re-exported) */ -/* ------------------------------------------------------------------ */ - -/** - * Parse `text_content` which may arrive as a JSON string or an object. - * Returns an object with known fields, or `undefined` if unusable. - */ -function parseTextContent( - raw: unknown, -): { headline?: string; body?: string; cta_text?: string; tagline?: string } | undefined { - let textContent = raw; - - if (typeof textContent === 'string') { - try { - textContent = JSON.parse(textContent); - } catch { - // Not valid JSON — treat as unusable - return undefined; - } - } - - if (typeof textContent !== 'object' || textContent === null) return undefined; - - const tc = textContent as Record; - return { - headline: tc.headline as string | undefined, - body: tc.body as string | undefined, - cta_text: (tc.cta_text ?? tc.cta) as string | undefined, - tagline: tc.tagline as string | undefined, - }; -} - -/** - * Resolve the best available image URL from a raw API response. - * - * Priority: explicit `image_url` (with blob rewrite) → base64 data URI. - * Pass `rewriteBlobs: true` (default) when restoring from a saved - * conversation; `false` when the response just came from the live API. - */ -function resolveImageUrl( - raw: { image_url?: string; image_base64?: string }, - rewriteBlobs = false, -): string | undefined { - let url = raw.image_url; - if (url && rewriteBlobs) { - url = rewriteBlobUrl(url); - } - if (url) return url; - if (raw.image_base64) return `data:image/png;base64,${raw.image_base64}`; - return undefined; -} - -/** - * Build a fully-typed `GeneratedContent` from an arbitrary raw API payload. - * - * @param raw The parsed JSON object from the backend. - * @param rewriteBlobs Pass `true` when restoring from a saved conversation - * so Azure Blob URLs get proxied. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function buildGeneratedContent(raw: any, rewriteBlobs = false): GeneratedContent { - const textContent = parseTextContent(raw.text_content); - const imageUrl = resolveImageUrl(raw, rewriteBlobs); - - return { - text_content: textContent, - image_content: - imageUrl || raw.image_prompt - ? { - image_url: imageUrl, - prompt_used: raw.image_prompt, - alt_text: raw.image_revised_prompt || 'Generated marketing image', - } - : undefined, - violations: raw.violations || [], - requires_modification: raw.requires_modification || false, - error: raw.error, - image_error: raw.image_error, - text_error: raw.text_error, - }; -} diff --git a/src/App/src/utils/downloadImage.ts b/src/App/src/utils/downloadImage.ts deleted file mode 100644 index 08e752c20..000000000 --- a/src/App/src/utils/downloadImage.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Download the generated marketing image with a product-name / tagline - * banner composited at the bottom. - * - * Falls back to a plain download when canvas compositing fails. - */ -export async function downloadImage( - imageUrl: string, - productName?: string, - tagline?: string, -): Promise { - try { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) 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 the image at the top - ctx.drawImage(img, 0, 0); - - // White banner at the bottom - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, img.height, img.width, bannerHeight); - - ctx.strokeStyle = '#e5e5e5'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, img.height); - ctx.lineTo(img.width, img.height); - ctx.stroke(); - - // Headline text - const headlineText = productName || 'Your Product'; - const headlineFontSize = Math.max(18, Math.min(36, img.width * 0.04)); - const taglineFontSize = Math.max(12, Math.min(20, img.width * 0.025)); - - ctx.font = `600 ${headlineFontSize}px Georgia, serif`; - ctx.fillStyle = '#1a1a1a'; - ctx.fillText( - headlineText, - padding, - img.height + padding + headlineFontSize * 0.8, - img.width - padding * 2, - ); - - // Tagline - if (tagline) { - 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); - const link = document.createElement('a'); - link.href = url; - link.download = 'generated-marketing-image.png'; - link.click(); - URL.revokeObjectURL(url); - } - }, 'image/png'); - }; - - img.onerror = () => { - plainDownload(imageUrl); - }; - - img.src = imageUrl; - } catch { - plainDownload(imageUrl); - } -} - -function plainDownload(url: string) { - const link = document.createElement('a'); - link.href = url; - link.download = 'generated-image.png'; - link.click(); -} diff --git a/src/App/src/utils/generationStages.ts b/src/App/src/utils/generationStages.ts deleted file mode 100644 index 03399bc0d..000000000 --- a/src/App/src/utils/generationStages.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Generation progress stage mapping. - * - * Pure function that converts elapsed seconds into a human-readable - * stage label + ordinal — used by the polling loop in `streamGenerateContent`. - */ - -export interface GenerationStage { - /** Ordinal stage index (0–5) for progress indicators. */ - stage: number; - /** Human-readable status message. */ - message: string; -} - -/** - * Map elapsed seconds to the current generation stage. - * - * Typical generation timeline: - * - 0 – 10 s → Briefing analysis - * - 10 – 25 s → Copy generation - * - 25 – 35 s → Image prompt creation - * - 35 – 55 s → Image generation - * - 55 – 70 s → Compliance check - * - 70 s+ → Finalizing - */ -export function getGenerationStage(elapsedSeconds: number): GenerationStage { - if (elapsedSeconds < 10) return { stage: 0, message: 'Analyzing creative brief...' }; - if (elapsedSeconds < 25) return { stage: 1, message: 'Generating marketing copy...' }; - if (elapsedSeconds < 35) return { stage: 2, message: 'Creating image prompt...' }; - if (elapsedSeconds < 55) return { stage: 3, message: 'Generating image with AI...' }; - if (elapsedSeconds < 70) return { stage: 4, message: 'Running compliance check...' }; - return { stage: 5, message: 'Finalizing content...' }; -} diff --git a/src/App/src/utils/index.ts b/src/App/src/utils/index.ts deleted file mode 100644 index 94ba048c3..000000000 --- a/src/App/src/utils/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Barrel export for all utility modules. - * - * Import everything you need from '../utils'. - */ - -// Message factories & formatting -export { createMessage, createErrorMessage } from './messageUtils'; - -// Content parsing (raw API → typed domain objects) -export { buildGeneratedContent } from './contentParsing'; - -// SSE stream parser -export { parseSSEStream } from './sseParser'; - -// Generation progress stages -export { getGenerationStage } from './generationStages'; - -// Brief-field metadata -export { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS } from './briefFields'; - -// String utilities -export { createNameSwapper, matchesAnyKeyword } from './stringUtils'; - -// Content error detection -export { isContentFilterError, getErrorMessage } from './contentErrors'; - -// Image download -export { downloadImage } from './downloadImage'; - -// Shared UI constants -export const AI_DISCLAIMER = 'AI-generated content may be incorrect'; diff --git a/src/App/src/utils/messageUtils.ts b/src/App/src/utils/messageUtils.ts deleted file mode 100644 index 45a7ea5ac..000000000 --- a/src/App/src/utils/messageUtils.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Message utilities — ChatMessage factory and formatting helpers. - * - * Replaces duplicated `msg()` helpers in useChatOrchestrator and - * useConversationActions with a single, tested source of truth. - */ -import { v4 as uuidv4 } from 'uuid'; -import type { ChatMessage } from '../types'; - -/** - * Create a `ChatMessage` literal with a fresh UUID and ISO timestamp. - */ -export function createMessage( - role: 'user' | 'assistant', - content: string, - agent?: string, -): ChatMessage { - return { - id: uuidv4(), - role, - content, - agent, - timestamp: new Date().toISOString(), - }; -} - -/** - * Shorthand for creating an assistant error message. - * Consolidates the repeated `createMessage('assistant', errorText)` pattern - * used in error catch blocks across multiple hooks. - */ -export function createErrorMessage(content: string): ChatMessage { - return createMessage('assistant', content); -} diff --git a/src/App/src/utils/sseParser.ts b/src/App/src/utils/sseParser.ts deleted file mode 100644 index 7767c0b5e..000000000 --- a/src/App/src/utils/sseParser.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * SSE (Server-Sent Events) stream parser. - * - * Eliminates the duplicated TextDecoder + buffer + line-split logic - * that was copy-pasted in `streamChat` and `streamRegenerateImage`. - */ -import type { AgentResponse } from '../types'; - -/** - * Parse an SSE stream from a `ReadableStreamDefaultReader` into an - * `AsyncGenerator` of `AgentResponse` objects. - * - * Protocol assumed: - * - Events delimited by `\n\n` - * - Each event starts with `data: ` - * - `data: [DONE]` terminates the stream - * - * @param reader The reader obtained via `response.body.getReader()` - */ -export async function* parseSSEStream( - reader: ReadableStreamDefaultReader, -): AsyncGenerator { - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6); - if (data === '[DONE]') { - return; - } - try { - yield JSON.parse(data) as AgentResponse; - } catch { - // Malformed SSE frame — skip silently - } - } - } - } -} diff --git a/src/App/src/utils/stringUtils.ts b/src/App/src/utils/stringUtils.ts deleted file mode 100644 index 387e07ff5..000000000 --- a/src/App/src/utils/stringUtils.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * String utilities — regex escaping, name swapping, keyword matching. - * - * Extracts the duplicated keyword-matching pattern and the regex-escape + - * swapName closure from useChatOrchestrator into reusable, testable functions. - */ - -/** - * Escape a string so it can be safely embedded in a `RegExp` pattern. - * @internal — only used by `createNameSwapper` within this module. - */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Create a function that replaces all case-insensitive occurrences of - * `oldName` with `newName` in a string. - * - * Returns `undefined` if no swap is possible (names are the same, etc.). - */ -export function createNameSwapper( - oldName: string | undefined, - newName: string | undefined, -): ((text?: string) => string | undefined) | undefined { - if (!oldName || !newName || oldName === newName) return undefined; - - const regex = new RegExp(escapeRegex(oldName), 'gi'); - return (text?: string) => { - if (!text) return text; - return text.replace(regex, () => newName); - }; -} - -/** - * Check whether `text` contains **any** of the given keywords - * (case-insensitive substring match). - * - * Used for intent classification (brief detection, refinement detection, - * image modification detection) repeated 3× in useChatOrchestrator. - */ -export function matchesAnyKeyword(text: string, keywords: readonly string[]): boolean { - const lower = text.toLowerCase(); - return keywords.some((kw) => lower.includes(kw)); -} diff --git a/src/App/.dockerignore b/src/app/.dockerignore similarity index 92% rename from src/App/.dockerignore rename to src/app/.dockerignore index 717b602c2..01b21e59f 100644 --- a/src/App/.dockerignore +++ b/src/app/.dockerignore @@ -9,7 +9,7 @@ # Build outputs (will be built in container) static/ **/dist/ -server/static/ +frontend-server/static/ # Development files *.log @@ -36,6 +36,7 @@ Thumbs.db # Deployment artifacts *.zip +frontend-deploy.zip # Backend not needed for frontend build (and vice versa) # Comment out if building full-stack image diff --git a/src/App/WebApp.Dockerfile b/src/app/WebApp.Dockerfile similarity index 82% rename from src/App/WebApp.Dockerfile rename to src/app/WebApp.Dockerfile index 15e91b831..fca82196e 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: React/Vite frontend + server (Node.js proxy) +# Combines: frontend (React/Vite) + frontend-server (Node.js proxy) # ============================================ # ============================================ @@ -11,14 +11,14 @@ FROM node:20-alpine AS frontend-build WORKDIR /app -# Copy package files -COPY package*.json ./ +# Copy frontend package files +COPY frontend/package*.json ./ # Install dependencies RUN npm ci -# Copy source code -COPY . ./ +# Copy frontend source code +COPY frontend/ ./ # 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 server package files -COPY server/package*.json ./ +# Copy frontend-server package files +COPY frontend-server/package*.json ./ # Install only production dependencies RUN npm ci --only=production # Copy the server code -COPY server/server.js ./ +COPY frontend-server/server.js ./ # Copy built frontend assets from stage 1 COPY --from=frontend-build /app/dist ./static diff --git a/src/App/server/package-lock.json b/src/app/frontend-server/package-lock.json similarity index 100% rename from src/App/server/package-lock.json rename to src/app/frontend-server/package-lock.json diff --git a/src/App/server/package.json b/src/app/frontend-server/package.json similarity index 100% rename from src/App/server/package.json rename to src/app/frontend-server/package.json diff --git a/src/App/server/server.js b/src/app/frontend-server/server.js similarity index 100% rename from src/App/server/server.js rename to src/app/frontend-server/server.js diff --git a/src/App/index.html b/src/app/frontend/index.html similarity index 100% rename from src/App/index.html rename to src/app/frontend/index.html diff --git a/src/App/microsoft.svg b/src/app/frontend/microsoft.svg similarity index 100% rename from src/App/microsoft.svg rename to src/app/frontend/microsoft.svg diff --git a/src/App/package-lock.json b/src/app/frontend/package-lock.json similarity index 98% rename from src/App/package-lock.json rename to src/app/frontend/package-lock.json index 1e9d5efcb..59a539864 100644 --- a/src/App/package-lock.json +++ b/src/app/frontend/package-lock.json @@ -9,12 +9,10 @@ "version": "1.0.0", "dependencies": { "@fluentui/react-components": "^9.54.0", - "@fluentui/react-icons": "^2.0.320", - "@reduxjs/toolkit": "^2.11.2", + "@fluentui/react-icons": "^2.0.245", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", - "react-redux": "^9.2.0", "uuid": "^10.0.0" }, "devDependencies": { @@ -1423,9 +1421,9 @@ } }, "node_modules/@fluentui/react-icons": { - "version": "2.0.320", - "resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.320.tgz", - "integrity": "sha512-NU4gErPeaTD/T6Z9g3Uvp898lIFS6fDLr3++vpT8pcI4Ds0fZqQdrwNi3dF0R/SVws8DXQaRYiGlPHxszo4J4g==", + "version": "2.0.315", + "resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.315.tgz", + "integrity": "sha512-IITWAQGgU7I32eHPDHi+TUCUF6malP27wZLUV3bqjGVF/x/lfxvTIx8yqv/cxuwF3+ITGFDpl+278ZYJtOI7ww==", "license": "MIT", "dependencies": { "@griffel/react": "^1.0.0", @@ -2651,32 +2649,6 @@ "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", @@ -3033,18 +3005,6 @@ "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", @@ -3191,12 +3151,6 @@ "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", @@ -4485,16 +4439,6 @@ "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", @@ -5785,30 +5729,6 @@ "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", @@ -5819,22 +5739,6 @@ "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", @@ -5868,12 +5772,6 @@ "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/package.json b/src/app/frontend/package.json similarity index 90% rename from src/App/package.json rename to src/app/frontend/package.json index d70638e23..2479885d7 100644 --- a/src/App/package.json +++ b/src/app/frontend/package.json @@ -11,12 +11,10 @@ }, "dependencies": { "@fluentui/react-components": "^9.54.0", - "@fluentui/react-icons": "^2.0.320", - "@reduxjs/toolkit": "^2.11.2", + "@fluentui/react-icons": "^2.0.245", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", - "react-redux": "^9.2.0", "uuid": "^10.0.0" }, "devDependencies": { diff --git a/src/app/frontend/src/App.tsx b/src/app/frontend/src/App.tsx new file mode 100644 index 000000000..bc6beff8c --- /dev/null +++ b/src/app/frontend/src/App.tsx @@ -0,0 +1,766 @@ +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 new file mode 100644 index 000000000..36781b77b --- /dev/null +++ b/src/app/frontend/src/api/index.ts @@ -0,0 +1,252 @@ +/** + * 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/src/components/BriefReview.tsx b/src/app/frontend/src/components/BriefReview.tsx similarity index 72% rename from src/App/src/components/BriefReview.tsx rename to src/app/frontend/src/components/BriefReview.tsx index b4a1ce1de..6ea755905 100644 --- a/src/App/src/components/BriefReview.tsx +++ b/src/app/frontend/src/components/BriefReview.tsx @@ -1,11 +1,9 @@ -import { memo, useMemo } from 'react'; import { Button, Text, tokens, } from '@fluentui/react-components'; import type { CreativeBrief } from '../types'; -import { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS, AI_DISCLAIMER } from '../utils'; interface BriefReviewProps { brief: CreativeBrief; @@ -14,22 +12,47 @@ interface BriefReviewProps { isAwaitingResponse?: boolean; } -export const BriefReview = memo(function BriefReview({ +// 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({ brief, onConfirm, onStartOver, isAwaitingResponse = false, }: BriefReviewProps) { - const { populatedFields, missingFields, populatedDisplayFields } = useMemo(() => { - const populated = BRIEF_FIELD_KEYS.filter(key => brief[key]?.trim()).length; - const missing = BRIEF_FIELD_KEYS.filter(key => !brief[key]?.trim()); + 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' }, + ]; - return { - populatedFields: populated, - missingFields: missing, - populatedDisplayFields: BRIEF_DISPLAY_ORDER.filter(({ key }) => brief[key]?.trim()), - }; - }, [brief]); + // Filter to only populated fields + const populatedDisplayFields = displayOrder.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 => BRIEF_FIELD_LABELS[f]).join(', ')}. + You are missing: {missingFields.map(f => fieldLabels[f]).join(', ')}.

You can tell me things like:
    @@ -154,10 +177,9 @@ export const BriefReview = memo(function BriefReview({ paddingTop: '8px', }}> - {AI_DISCLAIMER} + AI-generated content may be incorrect
); -}); -BriefReview.displayName = 'BriefReview'; +} diff --git a/src/app/frontend/src/components/ChatHistory.tsx b/src/app/frontend/src/components/ChatHistory.tsx new file mode 100644 index 000000000..f258d2a48 --- /dev/null +++ b/src/app/frontend/src/components/ChatHistory.tsx @@ -0,0 +1,643 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + Button, + Text, + Spinner, + tokens, + Link, + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + Input, + Dialog, + DialogSurface, + DialogTitle, + DialogBody, + DialogActions, + DialogContent, +} from '@fluentui/react-components'; +import { + Chat24Regular, + MoreHorizontal20Regular, + Compose20Regular, + Delete20Regular, + Edit20Regular, + DismissCircle20Regular, +} from '@fluentui/react-icons'; + +interface ConversationSummary { + id: string; + title: string; + lastMessage: string; + timestamp: string; + messageCount: number; +} + +interface ChatHistoryProps { + currentConversationId: string; + currentConversationTitle?: string | null; + currentMessages?: { role: string; content: string }[]; // Current session messages + onSelectConversation: (conversationId: string) => void; + onNewConversation: () => void; + refreshTrigger?: number; // Increment to trigger refresh + isGenerating?: boolean; // True when content generation is in progress +} + +export function ChatHistory({ + currentConversationId, + currentConversationTitle, + currentMessages = [], + onSelectConversation, + onNewConversation, + refreshTrigger = 0, + isGenerating = false +}: ChatHistoryProps) { + const [conversations, setConversations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [showAll, setShowAll] = useState(false); + const [isClearAllDialogOpen, setIsClearAllDialogOpen] = useState(false); + const [isClearing, setIsClearing] = useState(false); + const INITIAL_COUNT = 5; + + 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); + } finally { + setIsClearing(false); + } + }, [onNewConversation]); + + 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'); + } + } catch (err) { + console.error('Error deleting conversation:', err); + } + }, [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); + } + }, []); + + const loadConversations = useCallback(async () => { + 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); + setError('Unable to load conversation history'); + setConversations([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadConversations(); + }, [loadConversations, refreshTrigger]); + + // Reset showAll when conversations change significantly + useEffect(() => { + setShowAll(false); + }, [refreshTrigger]); + + // Build the current session conversation summary if it has messages + const currentSessionConversation: ConversationSummary | null = + currentMessages.length > 0 && currentConversationTitle ? { + id: currentConversationId, + title: currentConversationTitle, + lastMessage: currentMessages[currentMessages.length - 1]?.content?.substring(0, 100) || '', + timestamp: new Date().toISOString(), + messageCount: currentMessages.length, + } : null; + + // Merge current session with saved conversations, updating the current one with live data + const displayConversations = (() => { + // Find if current conversation exists in saved list + const existingIndex = conversations.findIndex(c => c.id === currentConversationId); + + if (existingIndex >= 0 && currentSessionConversation) { + // Update the saved conversation with current session data (live message count) + const updated = [...conversations]; + updated[existingIndex] = { + ...updated[existingIndex], + messageCount: currentMessages.length, + lastMessage: currentMessages[currentMessages.length - 1]?.content?.substring(0, 100) || updated[existingIndex].lastMessage, + }; + return updated; + } else if (currentSessionConversation) { + // Add current session at the top if it has messages and isn't saved yet + return [currentSessionConversation, ...conversations]; + } + return conversations; + })(); + + const visibleConversations = showAll ? displayConversations : displayConversations.slice(0, INITIAL_COUNT); + const hasMore = displayConversations.length > INITIAL_COUNT; + + return ( +
+
+ + Chat History + + + + +
+ +
+ +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ {error} + + Retry + +
+ ) : displayConversations.length === 0 ? ( +
+ + No conversations yet +
+ ) : ( + <> + {visibleConversations.map((conversation) => ( + onSelectConversation(conversation.id)} + onDelete={handleDeleteConversation} + onRename={handleRenameConversation} + onRefresh={loadConversations} + disabled={isGenerating} + /> + ))} + + )} + +
+ {hasMore && ( + setShowAll(!showAll)} + style={{ + fontSize: '13px', + color: isGenerating ? tokens.colorNeutralForegroundDisabled : tokens.colorBrandForeground1, + cursor: isGenerating ? 'not-allowed' : 'pointer', + pointerEvents: isGenerating ? 'none' : 'auto', + }} + > + {showAll ? 'Show less' : 'See all'} + + )} + + + Start new chat + +
+
+ + {/* Clear All Confirmation Dialog */} + !isClearing && setIsClearAllDialogOpen(data.open)}> + + Clear all chat history + + + + Are you sure you want to delete all chat history? This action cannot be undone and all conversations will be permanently removed. + + + + + + + + + +
+ ); +} + +interface ConversationItemProps { + conversation: ConversationSummary; + isActive: boolean; + onSelect: () => void; + onDelete: (conversationId: string) => void; + onRename: (conversationId: string, newTitle: string) => void; + onRefresh: () => void; + disabled?: boolean; +} + +function ConversationItem({ + conversation, + isActive, + onSelect, + onDelete, + onRename, + onRefresh, + disabled = false, +}: ConversationItemProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [renameValue, setRenameValue] = useState(conversation.title || ''); + const [renameError, setRenameError] = useState(''); + const renameInputRef = useRef(null); + + const handleRenameClick = () => { + setRenameValue(conversation.title || ''); + setRenameError(''); + setIsRenameDialogOpen(true); + setIsMenuOpen(false); + }; + + const handleRenameConfirm = async () => { + const trimmedValue = renameValue.trim(); + + // Validate before API call + if (trimmedValue.length < 5) { + setRenameError('Conversation name must be at least 5 characters'); + return; + } + if (trimmedValue.length > 50) { + setRenameError('Conversation name cannot exceed 50 characters'); + return; + } + if (!/[a-zA-Z0-9]/.test(trimmedValue)) { + setRenameError('Conversation name must contain at least one letter or number'); + return; + } + + if (trimmedValue === conversation.title) { + setIsRenameDialogOpen(false); + setRenameError(''); + return; + } + + await onRename(conversation.id, trimmedValue); + onRefresh(); + setIsRenameDialogOpen(false); + setRenameError(''); + }; + + const handleDeleteClick = () => { + setIsDeleteDialogOpen(true); + setIsMenuOpen(false); + }; + + const handleDeleteConfirm = async () => { + await onDelete(conversation.id); + setIsDeleteDialogOpen(false); + }; + + useEffect(() => { + if (isRenameDialogOpen && renameInputRef.current) { + renameInputRef.current.focus(); + renameInputRef.current.select(); + } + }, [isRenameDialogOpen]); + + return ( + <> +
+ + {conversation.title || 'Untitled'} + + + setIsMenuOpen(data.open)}> + + +
+ + setIsRenameDialogOpen(data.open)}> + + Rename conversation + + + { + 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 (!/[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 { + setRenameError(''); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && renameValue.trim()) { + handleRenameConfirm(); + } else if (e.key === 'Escape') { + setIsRenameDialogOpen(false); + } + }} + placeholder="Enter conversation name" + style={{ width: '100%' }} + /> + + Maximum 50 characters ({renameValue.length}/50) + + {renameError && ( + + {renameError} + + )} + + + + + + + + + + setIsDeleteDialogOpen(data.open)}> + + Delete conversation + + + + Are you sure you want to delete "{conversation.title || 'Untitled'}"? This action cannot be undone. + + + + + + + + + + + ); +} diff --git a/src/app/frontend/src/components/ChatPanel.tsx b/src/app/frontend/src/components/ChatPanel.tsx new file mode 100644 index 000000000..bf757acf9 --- /dev/null +++ b/src/app/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,437 @@ +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/src/components/ConfirmedBriefView.tsx b/src/app/frontend/src/components/ConfirmedBriefView.tsx similarity index 74% rename from src/App/src/components/ConfirmedBriefView.tsx rename to src/app/frontend/src/components/ConfirmedBriefView.tsx index e6697151a..e7feb9416 100644 --- a/src/App/src/components/ConfirmedBriefView.tsx +++ b/src/app/frontend/src/components/ConfirmedBriefView.tsx @@ -1,4 +1,3 @@ -import { memo } from 'react'; import { Text, Badge, @@ -8,13 +7,24 @@ import { Checkmark20Regular, } from '@fluentui/react-icons'; import type { CreativeBrief } from '../types'; -import { BRIEF_DISPLAY_ORDER } from '../utils'; interface ConfirmedBriefViewProps { brief: CreativeBrief; } -export const ConfirmedBriefView = memo(function ConfirmedBriefView({ brief }: ConfirmedBriefViewProps) { +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) { return (
- {BRIEF_DISPLAY_ORDER.map(({ key, label }) => { + {briefFields.map(({ key, label }) => { const value = brief[key]; if (!value?.trim()) return null; @@ -78,5 +88,4 @@ export const ConfirmedBriefView = memo(function ConfirmedBriefView({ brief }: Co
); -}); -ConfirmedBriefView.displayName = 'ConfirmedBriefView'; +} diff --git a/src/app/frontend/src/components/InlineContentPreview.tsx b/src/app/frontend/src/components/InlineContentPreview.tsx new file mode 100644 index 000000000..3ee0eead2 --- /dev/null +++ b/src/app/frontend/src/components/InlineContentPreview.tsx @@ -0,0 +1,561 @@ +import { useState, useEffect } from 'react'; +import { + Button, + Text, + Badge, + Divider, + tokens, + Tooltip, + Accordion, + AccordionItem, + AccordionHeader, + AccordionPanel, +} from '@fluentui/react-components'; +import { + ArrowSync20Regular, + CheckmarkCircle20Regular, + Warning20Regular, + Info20Regular, + ErrorCircle20Regular, + Copy20Regular, + ArrowDownload20Regular, + ShieldError20Regular, +} from '@fluentui/react-icons'; +import type { GeneratedContent, ComplianceViolation, Product } from '../types'; + +interface InlineContentPreviewProps { + content: GeneratedContent; + onRegenerate: () => void; + isLoading?: boolean; + selectedProduct?: Product; + 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({ + content, + onRegenerate, + isLoading, + selectedProduct, + imageGenerationEnabled = true, +}: InlineContentPreviewProps) { + const { text_content, image_content, violations, requires_modification, error, image_error, text_error } = content; + const [copied, setCopied] = useState(false); + const windowWidth = useWindowSize(); + + 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} ✨`, + text_content?.body, + text_content?.tagline, + ].filter(Boolean).join('\n\n'); + + navigator.clipboard.writeText(textToCopy); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleDownloadImage = async () => { + 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(); + } + } + }; + + // Get product display name + const getProductDisplayName = () => { + if (selectedProduct) { + return selectedProduct.product_name; + } + return text_content?.headline || 'Your Content'; + }; + + return ( +
+ {/* Selection confirmation */} + {selectedProduct && ( + + You selected "{selectedProduct.product_name}". Here's what I've created – let me know if you need anything changed. + + )} + + {/* Sparkle Headline - Figma style */} + {text_content?.headline && ( + + ✨ Discover the serene elegance of {getProductDisplayName()}. + + )} + + {/* Body Copy */} + {text_content?.body && ( + + {text_content.body} + + )} + + {/* Hashtags */} + {text_content?.tagline && ( + + {text_content.tagline} + + )} + + {/* Error Banner */} + {(error || text_error) && !violations.some(v => v.message.toLowerCase().includes('filter')) && ( +
+ +
+ + {getErrorMessage(error || text_error).title} + + + {getErrorMessage(error || text_error).description} + +
+
+ )} + + {/* Image Preview - with bottom banner for text */} + {imageGenerationEnabled && image_content?.image_url && ( +
+ {/* Image container */} +
+ {image_content.alt_text + + {/* Download button on image */} + +
+ + {/* Text banner below image */} +
+ + {selectedProduct?.product_name || text_content?.headline || 'Your Product'} + + {text_content?.tagline && ( + + {text_content.tagline} + + )} +
+
+ )} + + {/* Image Error State */} + {imageGenerationEnabled && !image_content?.image_url && (image_error || error) && ( +
+ + + {getErrorMessage(image_error || error).title} + + + Click Regenerate to try again + +
+ )} + + + + {/* User guidance callout for compliance status */} + {requires_modification ? ( +
+ + Action needed: This content has compliance issues that must be addressed before use. + Please review the details in the Compliance Guidelines section below and regenerate with modifications, + or manually edit the content to resolve the flagged items. + +
+ ) : violations.length > 0 ? ( +
+ + Optional review: This content is approved but has minor suggestions for improvement. + You can use it as-is or review the recommendations in the Compliance Guidelines section below. + +
+ ) : null} + + {/* Footer with actions */} +
+
+ {/* Approval Status Badge */} + {requires_modification ? ( + }> + Requires Modification + + ) : violations.length > 0 ? ( + }> + Review Recommended + + ) : ( + }> + Approved + + )} +
+ +
+ +
+
+ + {/* AI disclaimer */} + + AI-generated content may be incorrect + + + {/* Collapsible Compliance Section */} + {violations.length > 0 && ( + + + +
+ {requires_modification ? ( + + ) : violations.some(v => v.severity === 'error') ? ( + + ) : violations.some(v => v.severity === 'warning') ? ( + + ) : ( + + )} + + Compliance Guidelines ({violations.length} {violations.length === 1 ? 'item' : 'items'}) + +
+
+ +
+ {violations.map((violation, index) => ( + + ))} +
+
+
+
+ )} +
+ ); +} + +function ViolationCard({ violation }: { violation: ComplianceViolation }) { + const getSeverityStyles = () => { + switch (violation.severity) { + case 'error': + return { + icon: , + bg: '#fde7e9', + }; + case 'warning': + return { + icon: , + bg: '#fff4ce', + }; + case 'info': + return { + icon: , + bg: '#deecf9', + }; + } + }; + + const { icon, bg } = getSeverityStyles(); + + return ( +
+ {icon} +
+ + {violation.message} + + + {violation.suggestion} + +
+
+ ); +} diff --git a/src/app/frontend/src/components/ProductReview.tsx b/src/app/frontend/src/components/ProductReview.tsx new file mode 100644 index 000000000..9c0d9e960 --- /dev/null +++ b/src/app/frontend/src/components/ProductReview.tsx @@ -0,0 +1,227 @@ +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 new file mode 100644 index 000000000..a4c4540f6 --- /dev/null +++ b/src/app/frontend/src/components/SelectedProductView.tsx @@ -0,0 +1,138 @@ +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 + +
+
+ ))} +
+
+ ); +} diff --git a/src/app/frontend/src/components/WelcomeCard.tsx b/src/app/frontend/src/components/WelcomeCard.tsx new file mode 100644 index 000000000..ab5740708 --- /dev/null +++ b/src/app/frontend/src/components/WelcomeCard.tsx @@ -0,0 +1,158 @@ +import { + Card, + Text, + tokens, +} from '@fluentui/react-components'; +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, + } +]; + +interface WelcomeCardProps { + onSuggestionClick: (prompt: string) => void; + currentInput?: string; +} + +export function WelcomeCard({ onSuggestionClick, currentInput = '' }: WelcomeCardProps) { + const selectedIndex = suggestions.findIndex(s => s.prompt === currentInput); + return ( +
+ {/* Welcome card with suggestions inside */} +
+ {/* Header with icon and welcome message */} +
+ + Welcome to your Content Generation Accelerator + + + Here are the options I can assist you with today + +
+ + {/* Suggestion cards - vertical layout */} +
+ {suggestions.map((suggestion, index) => { + const isSelected = index === selectedIndex; + return ( + onSuggestionClick(suggestion.prompt)} + style={{ + padding: 'clamp(12px, 2vw, 16px)', + cursor: 'pointer', + backgroundColor: isSelected ? tokens.colorBrandBackground2 : tokens.colorNeutralBackground1, + border: 'none', + borderRadius: '16px', + transition: 'all 0.2s ease', + }} + onMouseEnter={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = tokens.colorBrandBackground2; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = tokens.colorNeutralBackground1; + } + }} + > +
+
+ Prompt icon +
+
+ + {suggestion.title} + +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/App/src/main.tsx b/src/app/frontend/src/main.tsx similarity index 60% rename from src/App/src/main.tsx rename to src/app/frontend/src/main.tsx index db4a8119d..e59c93df9 100644 --- a/src/App/src/main.tsx +++ b/src/app/frontend/src/main.tsx @@ -1,17 +1,13 @@ 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'; import App from './App'; import './styles/global.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + + + ); diff --git a/src/App/src/styles/global.css b/src/app/frontend/src/styles/global.css similarity index 100% rename from src/App/src/styles/global.css rename to src/app/frontend/src/styles/global.css diff --git a/src/app/frontend/src/styles/images/SamplePrompt.png b/src/app/frontend/src/styles/images/SamplePrompt.png new file mode 100644 index 000000000..9a57c6796 Binary files /dev/null and b/src/app/frontend/src/styles/images/SamplePrompt.png differ diff --git a/src/App/src/styles/images/contoso.svg b/src/app/frontend/src/styles/images/contoso.svg similarity index 100% rename from src/App/src/styles/images/contoso.svg rename to src/app/frontend/src/styles/images/contoso.svg diff --git a/src/App/src/styles/images/firstprompt.png b/src/app/frontend/src/styles/images/firstprompt.png similarity index 100% rename from src/App/src/styles/images/firstprompt.png rename to src/app/frontend/src/styles/images/firstprompt.png diff --git a/src/App/src/styles/images/secondprompt.png b/src/app/frontend/src/styles/images/secondprompt.png similarity index 100% rename from src/App/src/styles/images/secondprompt.png rename to src/app/frontend/src/styles/images/secondprompt.png diff --git a/src/App/src/types/index.ts b/src/app/frontend/src/types/index.ts similarity index 87% rename from src/App/src/types/index.ts rename to src/app/frontend/src/types/index.ts index 16a854bd4..33de59481 100644 --- a/src/App/src/types/index.ts +++ b/src/app/frontend/src/types/index.ts @@ -47,6 +47,14 @@ 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; @@ -64,6 +72,18 @@ 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; diff --git a/src/App/src/vite-env.d.ts b/src/app/frontend/src/vite-env.d.ts similarity index 100% rename from src/App/src/vite-env.d.ts rename to src/app/frontend/src/vite-env.d.ts diff --git a/src/App/tsconfig.json b/src/app/frontend/tsconfig.json similarity index 100% rename from src/App/tsconfig.json rename to src/app/frontend/tsconfig.json diff --git a/src/App/tsconfig.node.json b/src/app/frontend/tsconfig.node.json similarity index 100% rename from src/App/tsconfig.node.json rename to src/app/frontend/tsconfig.node.json diff --git a/src/App/vite.config.ts b/src/app/frontend/vite.config.ts similarity index 95% rename from src/App/vite.config.ts rename to src/app/frontend/vite.config.ts index 6f7ea5373..829c02469 100644 --- a/src/App/vite.config.ts +++ b/src/app/frontend/vite.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ }, }, build: { - outDir: './static', + outDir: '../static', emptyOutDir: true, }, });