From 38d8829118efa1a2163358a3d0c06b7dad18927e Mon Sep 17 00:00:00 2001 From: henrrypg Date: Mon, 6 Jul 2026 09:23:20 -0500 Subject: [PATCH] feat: add frontend for content suggestions --- frontend/src/ConfigurableAIAssistance.tsx | 6 + .../ContentSuggestionsRequest.test.tsx | 173 +++++++++++++++ .../components/ContentSuggestionsRequest.tsx | 210 ++++++++++++++++++ .../ContentSuggestionsResponse.test.tsx | 189 ++++++++++++++++ .../components/ContentSuggestionsResponse.tsx | 167 ++++++++++++++ .../components/SuggestionCard.tsx | 78 +++++++ .../data/workflowActions.ts | 62 ++++++ .../hooks/useAsyncTaskPolling.ts | 96 ++++++++ frontend/src/content-suggestions/index.ts | 3 + frontend/src/content-suggestions/messages.ts | 126 +++++++++++ frontend/src/content-suggestions/types.ts | 28 +++ frontend/src/index.tsx | 6 + 12 files changed, 1144 insertions(+) create mode 100644 frontend/src/content-suggestions/components/ContentSuggestionsRequest.test.tsx create mode 100644 frontend/src/content-suggestions/components/ContentSuggestionsRequest.tsx create mode 100644 frontend/src/content-suggestions/components/ContentSuggestionsResponse.test.tsx create mode 100644 frontend/src/content-suggestions/components/ContentSuggestionsResponse.tsx create mode 100644 frontend/src/content-suggestions/components/SuggestionCard.tsx create mode 100644 frontend/src/content-suggestions/data/workflowActions.ts create mode 100644 frontend/src/content-suggestions/hooks/useAsyncTaskPolling.ts create mode 100644 frontend/src/content-suggestions/index.ts create mode 100644 frontend/src/content-suggestions/messages.ts create mode 100644 frontend/src/content-suggestions/types.ts diff --git a/frontend/src/ConfigurableAIAssistance.tsx b/frontend/src/ConfigurableAIAssistance.tsx index ac5593cc..f428dae3 100644 --- a/frontend/src/ConfigurableAIAssistance.tsx +++ b/frontend/src/ConfigurableAIAssistance.tsx @@ -33,6 +33,10 @@ import { FlashcardCreator, FlashcardStudyResponse, } from './flashcard-study'; +import { + ContentSuggestionsRequest, + ContentSuggestionsResponse, +} from './content-suggestions'; import { PluginConfiguration } from './types'; import { WORKFLOW_ACTIONS, WorkflowActionType, NO_RESPONSE_MSG } from './constants'; @@ -48,6 +52,8 @@ import messages from './messages'; ['LibraryProblemCreatorResponse', LibraryProblemCreatorResponse], ['FlashcardCreator', FlashcardCreator], ['FlashcardStudyResponse', FlashcardStudyResponse], + ['ContentSuggestionsRequest', ContentSuggestionsRequest], + ['ContentSuggestionsResponse', ContentSuggestionsResponse], ].forEach(([id, component]) => registerEntry( REGISTRY_NAMES.COMPONENTS, { id: id as string, component: component as React.ComponentType }, diff --git a/frontend/src/content-suggestions/components/ContentSuggestionsRequest.test.tsx b/frontend/src/content-suggestions/components/ContentSuggestionsRequest.test.tsx new file mode 100644 index 00000000..571b8f95 --- /dev/null +++ b/frontend/src/content-suggestions/components/ContentSuggestionsRequest.test.tsx @@ -0,0 +1,173 @@ +import { screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper as render } from '../../setupTest'; +import ContentSuggestionsRequest from './ContentSuggestionsRequest'; +import { generateSuggestions, getSessionResponse } from '../data/workflowActions'; +import { useAsyncTaskPolling } from '../hooks/useAsyncTaskPolling'; + +jest.mock('../data/workflowActions', () => ({ + generateSuggestions: jest.fn(), + getSessionResponse: jest.fn(), +})); + +jest.mock('../hooks/useAsyncTaskPolling', () => ({ + POLLING_ERROR_KEYS: { TIMEOUT: 'timeout', GENERATE: 'generate', NETWORK: 'network' }, + useAsyncTaskPolling: jest.fn(), +})); + +const mockStartPolling = jest.fn(); +const mockStopPolling = jest.fn(); + +const defaultProps = { + hasAsked: false, + setResponse: jest.fn(), + setHasAsked: jest.fn(), + courseId: 'course-v1:Test+101+2024', + locationId: 'block-v1:Test+101+2024+type@vertical+block@abc', + uiSlotSelectorId: 'openedx.studio.course-outline.slot.v1', +}; + +beforeEach(() => { + jest.clearAllMocks(); + (useAsyncTaskPolling as jest.Mock).mockReturnValue({ + startPolling: mockStartPolling, + stopPolling: mockStopPolling, + }); +}); + +describe('ContentSuggestionsRequest', () => { + describe('when preloadPreviousSession is enabled', () => { + it('hands off to the response component when a session with suggestions exists', async () => { + const innerResponse = { suggestions: [{ id: '1', title: 'Fix typo' }], extraInstructions: 'be terse' }; + (getSessionResponse as jest.Mock).mockResolvedValue({ response: innerResponse, status: 'completed' }); + render(); + + await waitFor(() => { + expect(defaultProps.setResponse).toHaveBeenCalledWith(innerResponse); + expect(defaultProps.setHasAsked).toHaveBeenCalledWith(true); + }); + }); + + it('shows the ask button when no session exists', async () => { + (getSessionResponse as jest.Mock).mockResolvedValue({ response: { suggestions: [] }, status: 'no_suggestions' }); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /suggest improvements/i })).toBeInTheDocument(); + }); + }); + + it('does not call getSessionResponse when preloadPreviousSession is false', () => { + render(); + + expect(getSessionResponse).not.toHaveBeenCalled(); + }); + + it('hands off to the response component even when this location has zero suggestions, as long as the course has generated at least once', async () => { + const innerResponse = { suggestions: [], extraInstructions: 'be terse' }; + (getSessionResponse as jest.Mock).mockResolvedValue({ response: innerResponse, status: 'completed' }); + render(); + + await waitFor(() => { + expect(defaultProps.setResponse).toHaveBeenCalledWith(innerResponse); + expect(defaultProps.setHasAsked).toHaveBeenCalledWith(true); + }); + }); + + it('prefills the guidelines textarea with the course-stored extraInstructions', async () => { + const user = userEvent.setup(); + (getSessionResponse as jest.Mock).mockResolvedValue({ + response: { suggestions: [], extraInstructions: 'Always write in third person' }, + status: 'no_suggestions', + }); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /suggest improvements/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole('button', { name: /suggest improvements/i })); + + expect(screen.getByLabelText(/guidelines for the ai/i)).toHaveValue('Always write in third person'); + }); + }); + + it('hides the component once a response has been asked for', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the guidelines form when the button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /suggest improvements/i })); + + expect(screen.getByLabelText(/guidelines for the ai/i)).toBeInTheDocument(); + }); + + it('sends the typed guidelines as extraInstructions when generating', async () => { + const user = userEvent.setup(); + (generateSuggestions as jest.Mock).mockResolvedValue({ taskId: 'task-1' }); + render(); + + await user.click(screen.getByRole('button', { name: /suggest improvements/i })); + await user.type(screen.getByLabelText(/guidelines for the ai/i), 'Always write in third person'); + await user.click(screen.getByRole('button', { name: /generate suggestions/i })); + + await waitFor(() => { + expect(generateSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ extraInstructions: 'Always write in third person' }), + ); + }); + }); + + it('starts polling when the backend returns a task ID', async () => { + const user = userEvent.setup(); + (generateSuggestions as jest.Mock).mockResolvedValue({ taskId: 'task-123' }); + render(); + + await user.click(screen.getByRole('button', { name: /suggest improvements/i })); + await user.click(screen.getByRole('button', { name: /generate suggestions/i })); + + await waitFor(() => { + expect(mockStartPolling).toHaveBeenCalledWith('task-123'); + }); + }); + + it('sets the response directly when the backend returns data without a task ID', async () => { + const user = userEvent.setup(); + const responseData = { suggestions: [{ id: '1' }] }; + (generateSuggestions as jest.Mock).mockResolvedValue(responseData); + render(); + + await user.click(screen.getByRole('button', { name: /suggest improvements/i })); + await user.click(screen.getByRole('button', { name: /generate suggestions/i })); + + await waitFor(() => { + expect(defaultProps.setResponse).toHaveBeenCalledWith(responseData); + expect(defaultProps.setHasAsked).toHaveBeenCalledWith(true); + }); + }); + + it('shows an error message when the API call fails', async () => { + const user = userEvent.setup(); + (generateSuggestions as jest.Mock).mockRejectedValue(new Error('Server error')); + render(); + + await user.click(screen.getByRole('button', { name: /suggest improvements/i })); + await user.click(screen.getByRole('button', { name: /generate suggestions/i })); + + await waitFor(() => { + expect(screen.getByText(/failed to generate content suggestions/i)).toBeInTheDocument(); + }); + }); + + it('shows a timeout message reported by the polling hook', () => { + render(); + + const { onError } = (useAsyncTaskPolling as jest.Mock).mock.calls[0][0]; + act(() => { onError('timeout'); }); + + expect(screen.getByText(/generation timed out/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/content-suggestions/components/ContentSuggestionsRequest.tsx b/frontend/src/content-suggestions/components/ContentSuggestionsRequest.tsx new file mode 100644 index 00000000..156e3d3c --- /dev/null +++ b/frontend/src/content-suggestions/components/ContentSuggestionsRequest.tsx @@ -0,0 +1,210 @@ +import { + useState, useCallback, useEffect, useMemo, +} from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Alert, Form, Icon, StatefulButton, +} from '@openedx/paragon'; +import { AutoAwesome, SpinnerSimple } from '@openedx/paragon/icons'; +import { POLLING_ERROR_KEYS, useAsyncTaskPolling } from '../hooks/useAsyncTaskPolling'; +import { generateSuggestions, getSessionResponse } from '../data/workflowActions'; +import { prepareContextData } from '../../services'; +import messages from '../messages'; +import { ContentSuggestionsStep } from '../types'; + +const ERROR_MESSAGES: Record = { + [POLLING_ERROR_KEYS.TIMEOUT]: 'ai.extensions.content.suggestions.error.timeout', + [POLLING_ERROR_KEYS.GENERATE]: 'ai.extensions.content.suggestions.error.generate', + [POLLING_ERROR_KEYS.NETWORK]: 'ai.extensions.content.suggestions.error.network', +}; + +export interface ContentSuggestionsRequestProps { + hasAsked: boolean; + setResponse: (response: any) => void; + setHasAsked: (hasAsked: boolean) => void; + courseId?: string; + locationId?: string; + uiSlotSelectorId?: string; + buttonText?: string; + customMessage?: string; + preloadPreviousSession?: boolean; +} + +const ContentSuggestionsRequest = ({ + hasAsked, + setResponse, + setHasAsked, + courseId = '', + locationId = '', + uiSlotSelectorId = '', + buttonText, + customMessage, + preloadPreviousSession = false, +}: ContentSuggestionsRequestProps) => { + const intl = useIntl(); + const [step, setStep] = useState(preloadPreviousSession ? 'loading' : 'idle'); + const [showForm, setShowForm] = useState(false); + const [guidelines, setGuidelines] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [progressMessage, setProgressMessage] = useState(''); + + const contextData = useMemo( + () => prepareContextData({ courseId, locationId, uiSlotSelectorId }), + [courseId, locationId, uiSlotSelectorId], + ); + + const onComplete = useCallback((responseData: any) => { + setStep('idle'); + setProgressMessage(''); + setResponse(responseData); + setHasAsked(true); + }, [setResponse, setHasAsked]); + + const onError = useCallback((errorKey: string) => { + setStep('error'); + setProgressMessage(''); + const messageKey = ERROR_MESSAGES[errorKey] || 'ai.extensions.content.suggestions.error.generate'; + setErrorMessage(intl.formatMessage(messages[messageKey])); + }, [intl]); + + const onProgress = useCallback((message: string) => { + setProgressMessage(message); + }, []); + + const { startPolling, stopPolling } = useAsyncTaskPolling({ + contextData, + courseId, + onComplete, + onError, + onProgress, + }); + + // Check for an existing course-wide session on mount + useEffect(() => { + if (!preloadPreviousSession) { return undefined; } + + let cancelled = false; + const checkSession = async () => { + try { + const data = await getSessionResponse({ context: contextData }); + if (cancelled) { return; } + const sessionResponse = data?.response as any; + setGuidelines(sessionResponse?.extraInstructions || ''); + if (data?.status === 'completed') { + setResponse(sessionResponse); + setHasAsked(true); + } else { + setStep('idle'); + } + } catch { + if (!cancelled) { setStep('idle'); } + } + }; + checkSession(); + return () => { cancelled = true; }; + }, [contextData, setResponse, setHasAsked, preloadPreviousSession]); + + const handleGenerate = async () => { + setShowForm(false); + setStep('generating'); + setProgressMessage(''); + try { + const data = await generateSuggestions({ context: contextData, extraInstructions: guidelines }); + if (data.taskId) { + startPolling(data.taskId); + if (data.message) { setProgressMessage(data.message); } + } else { + setResponse(data); + setHasAsked(true); + setStep('idle'); + } + } catch { + setStep('error'); + setErrorMessage(intl.formatMessage(messages['ai.extensions.content.suggestions.error.generate'])); + } + }; + + const handleStartOver = () => { + stopPolling(); + setStep('idle'); + setErrorMessage(''); + setProgressMessage(''); + }; + + if (hasAsked) { return null; } + + return ( +
+ + {(!errorMessage && showForm) ? ( +
+ + {intl.formatMessage(messages['ai.extensions.content.suggestions.guidelines.label'])} + ) => setGuidelines(e.target.value)} + /> + {intl.formatMessage(messages['ai.extensions.content.suggestions.guidelines.help'])} + +
+ setShowForm(false)} + /> + }} + onClick={handleGenerate} + /> +
+
+ ) : ( + <> + + {customMessage || intl.formatMessage(messages['ai.extensions.content.suggestions.request.description'])} + + setShowForm(!showForm)} + labels={{ + idle: buttonText || intl.formatMessage(messages['ai.extensions.content.suggestions.request.button']), + generating: progressMessage || intl.formatMessage(messages['ai.extensions.content.suggestions.request.generating']), + loading: intl.formatMessage(messages['ai.extensions.content.suggestions.request.loading']), + error: buttonText || intl.formatMessage(messages['ai.extensions.content.suggestions.request.button']), + }} + icons={{ + idle: , + generating: , + loading: , + }} + disabledStates={['loading', 'generating']} + /> + + )} + { + step === 'error' && ( + {errorMessage} + + ) + } +
+ ); +}; + +export default ContentSuggestionsRequest; diff --git a/frontend/src/content-suggestions/components/ContentSuggestionsResponse.test.tsx b/frontend/src/content-suggestions/components/ContentSuggestionsResponse.test.tsx new file mode 100644 index 00000000..33740f68 --- /dev/null +++ b/frontend/src/content-suggestions/components/ContentSuggestionsResponse.test.tsx @@ -0,0 +1,189 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper as render } from '../../setupTest'; +import ContentSuggestionsResponse from './ContentSuggestionsResponse'; +import { clearSession } from '../data/workflowActions'; +import { ContentSuggestion } from '../types'; + +jest.mock('../data/workflowActions', () => ({ + clearSession: jest.fn().mockResolvedValue({}), +})); + +const makeSuggestion = (overrides: Partial = {}): ContentSuggestion => ({ + id: '1', + unitId: 'block-v1:Test+101+2024+type@vertical+block@abc', + unitDisplayName: 'Introduction', + type: 'wording', + priority: 'high', + title: 'Rename this unit', + suggestion: 'The title is unclear, consider something more specific.', + proposedChange: null, + sectionId: 'block-v1:Test+101+2024+type@chapter+block@sec1', + sectionDisplayName: 'Week 1', + subsectionId: 'block-v1:Test+101+2024+type@sequential+block@sub1', + subsectionDisplayName: 'Getting Started', + ...overrides, +}); + +// Matches makeSuggestion()'s default unitId — puts the response in "unit scope". +const unitLocationProps = { + onClear: jest.fn(), + contextData: { + courseId: 'course-v1:Test+101+2024', + locationId: 'block-v1:Test+101+2024+type@vertical+block@abc', + uiSlotSelectorId: 'educator-1', + }, +}; + +// A course-outline-style scope: locationId doesn't match any suggestion's unitId. +const outlineLocationProps = { + onClear: jest.fn(), + contextData: { + courseId: 'course-v1:Test+101+2024', + locationId: 'course-v1:Test+101+2024', + uiSlotSelectorId: 'ai-assist-button-course-outline-sidebar1', + }, +}; + +const assignMock = jest.fn(); + +beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { ...window.location, assign: assignMock }, + writable: true, + }); +}); + +beforeEach(() => { + jest.clearAllMocks(); + (clearSession as jest.Mock).mockResolvedValue({}); +}); + +describe('ContentSuggestionsResponse', () => { + describe('when there is no response yet', () => { + it('renders nothing', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing while loading', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('when there is an error', () => { + it('shows the error message', () => { + render(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + }); + + describe('when suggestions are empty', () => { + it('shows the empty-state message instead of any cards or a request button', () => { + render(); + expect(screen.getByText(/no suggestions for this content/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /suggest improvements/i })).not.toBeInTheDocument(); + }); + }); + + describe('in course-outline scope (locationId matches no suggestion)', () => { + it('renders one card per suggestion with its breadcrumb, title, and an Open unit link', () => { + const response = { suggestions: [makeSuggestion()], extraInstructions: '' }; + render(); + + expect(screen.getByText('Rename this unit')).toBeInTheDocument(); + expect(screen.getByText(/week 1.*getting started.*introduction/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /open unit/i })).toBeInTheDocument(); + }); + + it('renders a proposed change diff when one is present', () => { + const suggestion = makeSuggestion({ + proposedChange: { field: 'display_name', current: 'Unit 1', suggested: 'Getting Started with Python' }, + }); + render(); + + expect(screen.getByText('Unit 1')).toBeInTheDocument(); + expect(screen.getByText('Getting Started with Python')).toBeInTheDocument(); + }); + + it('does not render a diff block when proposedChange is null', () => { + render(); + expect(screen.queryByText(/current/i)).not.toBeInTheDocument(); + }); + }); + + describe('in unit scope (locationId matches every suggestion)', () => { + it('hides the Open unit link since we are already on that unit', () => { + render(); + expect(screen.queryByRole('link', { name: /open unit/i })).not.toBeInTheDocument(); + }); + + it('shows a single card with no navigation controls when there is only one suggestion', () => { + render(); + expect(screen.getByText('Rename this unit')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /next suggestion/i })).not.toBeInTheDocument(); + }); + + it('pages between suggestions with previous/next controls', async () => { + const user = userEvent.setup(); + const response = { + suggestions: [ + makeSuggestion({ id: '1', title: 'First suggestion' }), + makeSuggestion({ id: '2', title: 'Second suggestion' }), + ], + }; + render(); + + expect(screen.getByText('First suggestion')).toBeInTheDocument(); + expect(screen.getByText('1 of 2')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous suggestion/i })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: /next suggestion/i })); + + expect(screen.getByText('Second suggestion')).toBeInTheDocument(); + expect(screen.queryByText('First suggestion')).not.toBeInTheDocument(); + expect(screen.getByText('2 of 2')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /next suggestion/i })).toBeDisabled(); + }); + + it('redirects to the next unit in the course when it differs from the current one', async () => { + const user = userEvent.setup(); + const otherUnitId = 'block-v1:Test+101+2024+type@vertical+block@xyz'; + const response = { + suggestions: [makeSuggestion({ id: '1', title: 'Here' })], + courseSuggestions: [ + makeSuggestion({ id: '1', title: 'Here' }), + makeSuggestion({ id: '2', title: 'Elsewhere', unitId: otherUnitId }), + ], + }; + render(); + + await user.click(screen.getByRole('button', { name: /next suggestion/i })); + + expect(assignMock).toHaveBeenCalledWith(expect.stringContaining(otherUnitId)); + expect(screen.getByText('Here')).toBeInTheDocument(); + }); + }); + + describe('clear', () => { + it('clears the session and notifies the parent', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^clear$/i })); + + await waitFor(() => { + expect(clearSession).toHaveBeenCalled(); + expect(unitLocationProps.onClear).toHaveBeenCalled(); + }); + }); + + it('does not render a regenerate button', () => { + render(); + expect(screen.queryByRole('button', { name: /regenerate/i })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/content-suggestions/components/ContentSuggestionsResponse.tsx b/frontend/src/content-suggestions/components/ContentSuggestionsResponse.tsx new file mode 100644 index 00000000..f02ceecc --- /dev/null +++ b/frontend/src/content-suggestions/components/ContentSuggestionsResponse.tsx @@ -0,0 +1,167 @@ +import { + useState, useCallback, useEffect, useMemo, +} from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; +import { Alert, Button } from '@openedx/paragon'; +import { ChevronLeft, ChevronRight } from '@openedx/paragon/icons'; +import SuggestionCard, { buildUnitUrl } from './SuggestionCard'; +import { clearSession } from '../data/workflowActions'; +import { ContentSuggestion } from '../types'; +import { prepareContextData } from '../../services'; +import messages from '../messages'; + +export interface ContentSuggestionsResponseProps { + response: any; + error?: string; + isLoading?: boolean; + contextData?: Record; + customMessage?: string; + onClear?: () => void; +} + +const parseList = (data: any, key: string): ContentSuggestion[] => { + if (Array.isArray(data?.[key])) { return data[key]; } + return []; +}; + +const ContentSuggestionsResponse = ({ + response, + error, + isLoading, + customMessage, + contextData = {}, + onClear, +}: ContentSuggestionsResponseProps) => { + const intl = useIntl(); + const preparedContext = useMemo(() => prepareContextData(contextData), [contextData]); + const locationId = preparedContext.locationId || ''; + + const [suggestions, setSuggestions] = useState(() => parseList(response, 'suggestions')); + // Falls back to the location-filtered list for sessions generated before + // course_suggestions existed — self-heals once the course is regenerated. + const [courseSuggestions, setCourseSuggestions] = useState( + () => { + const parsed = parseList(response, 'courseSuggestions'); + return parsed.length > 0 ? parsed : parseList(response, 'suggestions'); + }, + ); + const [index, setIndex] = useState(0); + + useMemo(() => { + const filtered = parseList(response, 'suggestions'); + const course = parseList(response, 'courseSuggestions'); + setSuggestions(filtered); + setCourseSuggestions(course.length > 0 ? course : filtered); + }, [response]); + + // Start the carousel on this unit's own suggestion within the full course + // order, so "next/previous" walks the whole course from the right spot. + useEffect(() => { + const startIndex = locationId + ? courseSuggestions.findIndex((s) => s.unitId === locationId) + : -1; + setIndex(startIndex >= 0 ? startIndex : 0); + }, [courseSuggestions, locationId]); + + const handleClear = useCallback(async () => { + try { + await clearSession({ context: preparedContext }); + } catch (err) { + logError('ContentSuggestionsResponse: clear session error:', err); + } + if (onClear) { onClear(); } + }, [preparedContext, onClear]); + + if (isLoading || (!response && !error)) { return null; } + + if (error) { + return {error}; + } + + // When every suggestion in the (already location-filtered) list belongs to + // the unit we're currently on, "Open unit" is redundant — page through the + // whole course instead, redirecting whenever the target suggestion lives + // on a different unit. + const isUnitScope = Boolean(locationId) && suggestions.length > 0 + && suggestions.every((s) => s.unitId === locationId); + + const goTo = (nextIndex: number) => { + const target = courseSuggestions[nextIndex]; + if (!target) { return; } + if (target.unitId === locationId) { + setIndex(nextIndex); + } else { + window.location.assign(buildUnitUrl(target.unitId)); + } + }; + + const renderBody = () => { + if (suggestions.length === 0) { + return ( +

+ {intl.formatMessage(messages['ai.extensions.content.suggestions.response.empty'])} +

+ ); + } + if (!isUnitScope) { + return suggestions.map((suggestion) => ( + + )); + } + return ( + <> + + {courseSuggestions.length > 1 && ( +
+ + + {intl.formatMessage(messages['ai.extensions.content.suggestions.nav.counter'], { + current: index + 1, + total: courseSuggestions.length, + })} + + +
+ )} + + ); + }; + + return ( +
+
+

+ {customMessage || intl.formatMessage(messages['ai.extensions.content.suggestions.response.title'])} +

+ +
+ + {renderBody()} +
+ ); +}; + +export default ContentSuggestionsResponse; diff --git a/frontend/src/content-suggestions/components/SuggestionCard.tsx b/frontend/src/content-suggestions/components/SuggestionCard.tsx new file mode 100644 index 00000000..296e7059 --- /dev/null +++ b/frontend/src/content-suggestions/components/SuggestionCard.tsx @@ -0,0 +1,78 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import { + Badge, Card, Hyperlink, +} from '@openedx/paragon'; +import { ContentSuggestion } from '../types'; +import messages from '../messages'; + +const PRIORITY_VARIANTS: Record = { + high: 'danger', + medium: 'warning', + low: 'info', +}; + +const PRIORITY_MESSAGE_KEYS: Record = { + high: 'ai.extensions.content.suggestions.priority.high', + medium: 'ai.extensions.content.suggestions.priority.medium', + low: 'ai.extensions.content.suggestions.priority.low', +}; + +// ponytail: Studio's container URL only needs the unit's usage key, no course path. +export const buildUnitUrl = (unitId: string): string => `${getConfig().STUDIO_BASE_URL}/container/${unitId}`; + +export interface SuggestionCardProps { + suggestion: ContentSuggestion; + currentLocationId?: string; +} + +const SuggestionCard = ({ suggestion, currentLocationId = '' }: SuggestionCardProps) => { + const intl = useIntl(); + const { + unitId, unitDisplayName, sectionDisplayName, subsectionDisplayName, + type, priority, title, suggestion: suggestionText, proposedChange, + } = suggestion; + + const breadcrumb = [sectionDisplayName, subsectionDisplayName, unitDisplayName] + .filter(Boolean) + .join(' › '); + + return ( + + + {breadcrumb &&
{breadcrumb}
} +
+ + {intl.formatMessage(messages[PRIORITY_MESSAGE_KEYS[priority]])} + + {type} +
+

{title}

+

{suggestionText}

+ + {proposedChange && ( +
+
+ {intl.formatMessage(messages['ai.extensions.content.suggestions.card.current.label'])} + {' '} + {proposedChange.current} +
+
+ {intl.formatMessage(messages['ai.extensions.content.suggestions.card.suggested.label'])} + {' '} + {proposedChange.suggested} +
+
+ )} + + {unitId !== currentLocationId && ( + + {intl.formatMessage(messages['ai.extensions.content.suggestions.card.open.unit'])} + + )} +
+
+ ); +}; + +export default SuggestionCard; diff --git a/frontend/src/content-suggestions/data/workflowActions.ts b/frontend/src/content-suggestions/data/workflowActions.ts new file mode 100644 index 00000000..a6bef977 --- /dev/null +++ b/frontend/src/content-suggestions/data/workflowActions.ts @@ -0,0 +1,62 @@ +import { callWorkflowService } from '../../services'; +import { WORKFLOW_ACTIONS } from '../../constants'; + +interface ContextParam { + context: Record; +} + +// ── Generate content suggestions (async) ──────────────────────────────────── + +interface GenerateParams extends ContextParam { + extraInstructions: string; +} + +export const generateSuggestions = async ({ + context, extraInstructions, +}: GenerateParams) => callWorkflowService({ + context, + payload: { + action: WORKFLOW_ACTIONS.RUN_ASYNC, + requestId: `ai-request-${Date.now()}`, + userInput: { extraInstructions }, + }, +}); + +// ── Poll task status ──────────────────────────────────────────────────────── + +interface PollParams extends ContextParam { + taskId: string; + courseId: string; +} + +export const pollTaskStatus = async ({ + context, taskId, courseId, +}: PollParams) => callWorkflowService({ + context, + payload: { + action: WORKFLOW_ACTIONS.GET_RUN_STATUS, + requestId: `ai-poll-${Date.now()}`, + taskId, + courseId, + }, +}); + +// ── Get current session response ──────────────────────────────────────────── + +export const getSessionResponse = async ({ context }: ContextParam) => callWorkflowService({ + context, + payload: { + action: WORKFLOW_ACTIONS.GET_CURRENT_SESSION_RESPONSE, + requestId: `ai-request-${Date.now()}`, + }, +}); + +// ── Clear session ─────────────────────────────────────────────────────────── + +export const clearSession = async ({ context }: ContextParam) => callWorkflowService({ + context, + payload: { + action: WORKFLOW_ACTIONS.CLEAR_SESSION, + requestId: `ai-request-${Date.now()}`, + }, +}); diff --git a/frontend/src/content-suggestions/hooks/useAsyncTaskPolling.ts b/frontend/src/content-suggestions/hooks/useAsyncTaskPolling.ts new file mode 100644 index 00000000..dd7e2828 --- /dev/null +++ b/frontend/src/content-suggestions/hooks/useAsyncTaskPolling.ts @@ -0,0 +1,96 @@ +import { useRef, useCallback, useEffect } from 'react'; +import { logError } from '@edx/frontend-platform/logging'; +import { pollTaskStatus as pollTaskStatusApi } from '../data/workflowActions'; + +export const POLLING_INTERVALS = { INITIAL: 10000, EXTENDED: 30000 }; +export const POLLING_TIMEOUTS = { SWITCH_TO_EXTENDED: 2, MAX_DURATION: 5 }; +export const MS_TO_MINUTES = 60000; + +export const POLLING_ERROR_KEYS = { + TIMEOUT: 'timeout', + GENERATE: 'generate', + NETWORK: 'network', +} as const; + +interface UseAsyncTaskPollingOptions { + contextData: Record; + courseId: string; + onComplete: (responseData: any) => void; + onError: (errorKey: string, detail?: string) => void; + onProgress?: (message: string) => void; +} + +/** + * Polls the backend for the status of an async workflow task. + * + * Starts with an initial polling interval (10s) and switches to an extended + * interval (30s) after 2 minutes. Aborts with a timeout error after 5 minutes. + * Mirrors flashcard-study/hooks/useAsyncTaskPolling.ts. + */ +export const useAsyncTaskPolling = ({ + contextData, + courseId, + onComplete, + onError, + onProgress, +}: UseAsyncTaskPollingOptions) => { + const pollingIntervalRef = useRef | null>(null); + const pollingStartTimeRef = useRef(null); + + const stopPolling = useCallback(() => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }, []); + + const pollOnce = useCallback(async (taskId: string) => { + try { + const data = await pollTaskStatusApi({ context: contextData, taskId, courseId }); + + if (data.status === 'completed' || data.status === 'success') { + stopPolling(); + onComplete(data.response); + } else if (data.status === 'error' || data.status === 'timeout' || data.error) { + stopPolling(); + onError(POLLING_ERROR_KEYS.GENERATE, data.error || data.message); + } else if (data.message && onProgress) { + onProgress(data.message); + } + } catch (err) { + logError('content-suggestions useAsyncTaskPolling: poll error:', err); + stopPolling(); + onError(POLLING_ERROR_KEYS.NETWORK, (err as Error).message); + } + }, [contextData, courseId, onComplete, onError, onProgress, stopPolling]); + + const startPolling = useCallback((taskId: string) => { + pollingStartTimeRef.current = Date.now(); + pollOnce(taskId); + + let pollCount = 0; + pollingIntervalRef.current = setInterval(() => { + if (!pollingStartTimeRef.current) { return; } + const elapsedMinutes = (Date.now() - pollingStartTimeRef.current) / MS_TO_MINUTES; + pollCount += 1; + + if (elapsedMinutes >= POLLING_TIMEOUTS.MAX_DURATION) { + stopPolling(); + onError(POLLING_ERROR_KEYS.TIMEOUT); + return; + } + + if (elapsedMinutes >= POLLING_TIMEOUTS.SWITCH_TO_EXTENDED && pollCount === 12) { + stopPolling(); + pollingIntervalRef.current = setInterval(() => pollOnce(taskId), POLLING_INTERVALS.EXTENDED); + return; + } + + pollOnce(taskId); + }, POLLING_INTERVALS.INITIAL); + }, [pollOnce, stopPolling, onError]); + + useEffect(() => () => stopPolling(), [stopPolling]); + + return { startPolling, stopPolling }; +}; diff --git a/frontend/src/content-suggestions/index.ts b/frontend/src/content-suggestions/index.ts new file mode 100644 index 00000000..7c5b2740 --- /dev/null +++ b/frontend/src/content-suggestions/index.ts @@ -0,0 +1,3 @@ +export type { ContentSuggestionsStep, ContentSuggestion, ContentSuggestionsResult } from './types'; +export { default as ContentSuggestionsRequest } from './components/ContentSuggestionsRequest'; +export { default as ContentSuggestionsResponse } from './components/ContentSuggestionsResponse'; diff --git a/frontend/src/content-suggestions/messages.ts b/frontend/src/content-suggestions/messages.ts new file mode 100644 index 00000000..ec2dcca6 --- /dev/null +++ b/frontend/src/content-suggestions/messages.ts @@ -0,0 +1,126 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'ai.extensions.content.suggestions.request.description': { + id: 'ai.extensions.content.suggestions.request.description', + defaultMessage: 'Get AI suggestions to improve this course content', + description: 'Default description shown next to the suggest-improvements button', + }, + 'ai.extensions.content.suggestions.request.button': { + id: 'ai.extensions.content.suggestions.request.button', + defaultMessage: 'Suggest improvements', + description: 'Button label to request content improvement suggestions', + }, + 'ai.extensions.content.suggestions.request.loading': { + id: 'ai.extensions.content.suggestions.request.loading', + defaultMessage: 'Loading...', + description: 'Button label while the previous session is being loaded', + }, + 'ai.extensions.content.suggestions.request.generating': { + id: 'ai.extensions.content.suggestions.request.generating', + defaultMessage: 'Analyzing course content...', + description: 'Button label while suggestions are being generated', + }, + 'ai.extensions.content.suggestions.guidelines.label': { + id: 'ai.extensions.content.suggestions.guidelines.label', + defaultMessage: 'Guidelines for the AI (optional)', + description: 'Label for the free-text guidelines textarea', + }, + 'ai.extensions.content.suggestions.guidelines.placeholder': { + id: 'ai.extensions.content.suggestions.guidelines.placeholder', + defaultMessage: 'e.g. Always write in third person, keep a formal tone...', + description: 'Placeholder example text for the guidelines textarea', + }, + 'ai.extensions.content.suggestions.guidelines.help': { + id: 'ai.extensions.content.suggestions.guidelines.help', + defaultMessage: 'These guidelines are shared with every author on this course until someone changes them.', + description: 'Help text explaining guidelines are stored per-course', + }, + 'ai.extensions.content.suggestions.guidelines.submit': { + id: 'ai.extensions.content.suggestions.guidelines.submit', + defaultMessage: 'Generate suggestions', + description: 'Submit button label on the guidelines form', + }, + 'ai.extensions.content.suggestions.guidelines.cancel': { + id: 'ai.extensions.content.suggestions.guidelines.cancel', + defaultMessage: 'Cancel', + description: 'Cancel button label on the guidelines form', + }, + 'ai.extensions.content.suggestions.error.generate': { + id: 'ai.extensions.content.suggestions.error.generate', + defaultMessage: 'Failed to generate content suggestions. Please try again.', + description: 'Generic generation error', + }, + 'ai.extensions.content.suggestions.error.timeout': { + id: 'ai.extensions.content.suggestions.error.timeout', + defaultMessage: 'Generation timed out. Please try again.', + description: 'Error shown when generation polling exceeds the maximum duration', + }, + 'ai.extensions.content.suggestions.error.network': { + id: 'ai.extensions.content.suggestions.error.network', + defaultMessage: 'A network error occurred while checking suggestion status.', + description: 'Error shown when a poll request fails outright', + }, + 'ai.extensions.content.suggestions.response.title': { + id: 'ai.extensions.content.suggestions.response.title', + defaultMessage: 'Content suggestions', + description: 'Title for the suggestions response panel', + }, + 'ai.extensions.content.suggestions.response.empty': { + id: 'ai.extensions.content.suggestions.response.empty', + defaultMessage: 'No suggestions for this content right now.', + description: 'Shown when the suggestion list is empty for the current scope', + }, + 'ai.extensions.content.suggestions.nav.previous': { + id: 'ai.extensions.content.suggestions.nav.previous', + defaultMessage: 'Previous suggestion', + description: 'Accessible label for the previous-suggestion carousel button', + }, + 'ai.extensions.content.suggestions.nav.next': { + id: 'ai.extensions.content.suggestions.nav.next', + defaultMessage: 'Next suggestion', + description: 'Accessible label for the next-suggestion carousel button', + }, + 'ai.extensions.content.suggestions.nav.counter': { + id: 'ai.extensions.content.suggestions.nav.counter', + defaultMessage: '{current} of {total}', + description: 'Position indicator when paging through a unit\'s suggestions, e.g. "2 of 3"', + }, + 'ai.extensions.content.suggestions.response.clear': { + id: 'ai.extensions.content.suggestions.response.clear', + defaultMessage: 'Clear', + description: 'Button to clear the stored suggestions session', + }, + 'ai.extensions.content.suggestions.card.open.unit': { + id: 'ai.extensions.content.suggestions.card.open.unit', + defaultMessage: 'Open unit', + description: 'Link to open the affected unit in Studio', + }, + 'ai.extensions.content.suggestions.card.current.label': { + id: 'ai.extensions.content.suggestions.card.current.label', + defaultMessage: 'Current', + description: 'Label for the current value in a proposed change diff', + }, + 'ai.extensions.content.suggestions.card.suggested.label': { + id: 'ai.extensions.content.suggestions.card.suggested.label', + defaultMessage: 'Suggested', + description: 'Label for the suggested replacement value in a proposed change diff', + }, + 'ai.extensions.content.suggestions.priority.high': { + id: 'ai.extensions.content.suggestions.priority.high', + defaultMessage: 'High', + description: 'High priority badge label', + }, + 'ai.extensions.content.suggestions.priority.medium': { + id: 'ai.extensions.content.suggestions.priority.medium', + defaultMessage: 'Medium', + description: 'Medium priority badge label', + }, + 'ai.extensions.content.suggestions.priority.low': { + id: 'ai.extensions.content.suggestions.priority.low', + defaultMessage: 'Low', + description: 'Low priority badge label', + }, +}); + +export default messages; diff --git a/frontend/src/content-suggestions/types.ts b/frontend/src/content-suggestions/types.ts new file mode 100644 index 00000000..1eeb89e6 --- /dev/null +++ b/frontend/src/content-suggestions/types.ts @@ -0,0 +1,28 @@ +export type ContentSuggestionsStep = 'idle' | 'generating' | 'loading' | 'error'; + +export interface ProposedChange { + field: 'display_name' | 'content_html' | 'summary'; + current: string; + suggested: string; +} + +export interface ContentSuggestion { + id: string; + unitId: string; + unitDisplayName: string; + type: 'wording' | 'structure' | 'pedagogy' | 'accessibility'; + priority: 'high' | 'medium' | 'low'; + title: string; + suggestion: string; + proposedChange: ProposedChange | null; + sectionId: string | null; + sectionDisplayName: string | null; + subsectionId: string | null; + subsectionDisplayName: string | null; +} + +export interface ContentSuggestionsResult { + suggestions: ContentSuggestion[]; + courseSuggestions: ContentSuggestion[]; + extraInstructions: string; +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index fc9058b5..f8ff9b8d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -13,6 +13,10 @@ import { } from './library-problem-creator'; import FlashcardCreator from './flashcard-study/components/FlashcardCreator'; import FlashcardStudyResponse from './flashcard-study/components/FlashcardStudyResponse'; +import { + ContentSuggestionsRequest, + ContentSuggestionsResponse, +} from './content-suggestions'; export * as services from './services'; @@ -44,5 +48,7 @@ export { AIExtensionsCard, FlashcardCreator, FlashcardStudyResponse, + ContentSuggestionsRequest, + ContentSuggestionsResponse, }; export type { RegistryEntry };