From 09afd14a1dd38164a9889e81be7cbc32c9b62343 Mon Sep 17 00:00:00 2001 From: Stephen Fan Date: Tue, 24 Feb 2026 18:12:02 -0800 Subject: [PATCH 1/2] feat: create a h5p review enginee --- docs/chat-mode-spec.md | 801 ++++++++++++++++++ package-lock.json | 10 + package.json | 1 + .../controllers/h5pPreviewController.js | 404 +++++++++ routes/create/createRoutes.js | 6 +- routes/create/h5p-core/h5p-core.js | 738 ++++++++++++++++ server.js | 3 + src/App.tsx | 4 + src/pages/H5PPreview.tsx | 208 +++++ vite.config.mts | 5 + 10 files changed, 2178 insertions(+), 2 deletions(-) create mode 100644 docs/chat-mode-spec.md create mode 100644 routes/create/controllers/h5pPreviewController.js create mode 100644 routes/create/h5p-core/h5p-core.js create mode 100644 src/pages/H5PPreview.tsx diff --git a/docs/chat-mode-spec.md b/docs/chat-mode-spec.md new file mode 100644 index 0000000..79f6161 --- /dev/null +++ b/docs/chat-mode-spec.md @@ -0,0 +1,801 @@ +# Chat Mode Feature — Technical Specification + +## 1. Overview + +### 1.1 Feature Summary +Add an AI-guided conversational interface (Chat Mode) alongside the existing 4-tab workflow UI. Users toggle between modes via a button in the Header. Chat Mode follows the same workflow (Materials → Learning Objectives → Questions → Export) but with an AI assistant guiding each step through natural language conversation and embedded interactive components. + +### 1.2 Design Decisions +| Decision | Choice | Rationale | +|----------|--------|-----------| +| State sync direction | One-way (Chat → Workflow) | Simpler; chat dispatches Redux actions so workflow reflects changes | +| File upload in chat | Drag-and-drop / paste URL in input area | Natural chat UX, no modal interruption | +| Embedded UI granularity | Hybrid (Option C) | Inline UI for simple choices, modal for complex edits, suggest workflow switch for bulk edits | +| LLM provider for chat | OpenAI only | Mature function calling support required | +| Conversation persistence | MongoDB | Users can resume conversations across sessions | + +### 1.3 Constraints +- New files must not exceed 300 lines +- Follow existing codebase patterns (Redux Toolkit, Express Router, Mongoose, Shadcn/ui) +- Reuse existing services and API client where possible +- No changes to existing workflow behavior + +--- + +## 2. Architecture + +### 2.1 High-Level Flow + +``` +User message → Frontend ChatInput + → POST /api/create/chat/message (SSE stream) + → Backend ChatOrchestrator + → OpenAI API (with tool definitions) + → If tool_call: ChatToolExecutor runs internal service + → Return tool result to OpenAI for next response + → Stream assistant response + tool results to frontend + → Frontend renders message + dispatches Redux actions + → Workflow UI reflects changes via shared Redux store +``` + +### 2.2 Design Patterns + +| Pattern | Where | Purpose | +|---------|-------|---------| +| **Strategy** | `ChatToolExecutor` | Each tool maps to a strategy function that calls the appropriate service | +| **Observer** | Redux + PubSub (existing) | Chat actions dispatch Redux actions; workflow observes store changes | +| **Facade** | `ChatOrchestrationService` | Single entry point orchestrating LLM calls, tool execution, and streaming | +| **Adapter** | `useChatActions` hook | Adapts tool call results into Redux dispatch calls | +| **Factory** | `ChatMessageRenderer` | Renders different message types (text, tool result, inline UI, error) | + +### 2.3 Sequence Diagram + +``` +Frontend Backend OpenAI + │ │ │ + │ POST /chat/message │ │ + │ { conversationId, msg } │ │ + │──────────────────────────>│ │ + │ │ chat.completions.create │ + │ │ { messages, tools } │ + │ │──────────────────────────>│ + │ │ │ + │ │ tool_call: create_folder │ + │ │<──────────────────────────│ + │ │ │ + │ SSE: tool_call_start │ Execute: folderService │ + │<──────────────────────────│ │ + │ │ │ + │ SSE: tool_call_result │ Return result to OpenAI │ + │<──────────────────────────│──────────────────────────>│ + │ │ │ + │ │ Stream text response │ + │ SSE: text_chunk │<──────────────────────────│ + │<──────────────────────────│ │ + │ │ │ + │ SSE: message_complete │ │ + │<──────────────────────────│ │ + │ │ │ + │ Dispatch Redux action │ │ + │ (addQuizLocally, etc.) │ │ +``` + +--- + +## 3. Data Model + +### 3.1 Conversation (MongoDB) + +```javascript +// Model: Conversation +{ + _id: ObjectId, + user: ObjectId (ref: User), // Owner + title: String, // Auto-generated or user-set + folder: ObjectId (ref: Folder), // Associated folder (set during chat) + quiz: ObjectId (ref: Quiz), // Associated quiz (set during chat) + messages: [{ + role: 'user' | 'assistant' | 'system' | 'tool', + content: String, // Text content + toolCalls: [{ // For assistant messages with tool calls + id: String, // OpenAI tool_call_id + name: String, // Function name + arguments: Object, // Parsed arguments + result: Object, // Execution result + status: 'pending' | 'success' | 'error' + }], + metadata: { // For frontend rendering hints + inlineUI: { // Optional: embedded UI component spec + type: String, // 'checkbox-list' | 'button-group' | 'file-upload' | ... + props: Object, // Component-specific props + userResponse: Object // User's selection (filled after interaction) + } + }, + timestamp: Date + }], + context: { // Current workflow progress + step: 'init' | 'folder' | 'materials' | 'objectives' | 'plan' | 'questions' | 'export', + folderId: String, + quizId: String, + materialIds: [String], + objectiveIds: [String], + planId: String + }, + status: 'active' | 'completed' | 'archived', + createdAt: Date, + updatedAt: Date +} +``` + +### 3.2 Redux Chat State + +```typescript +// New slice: chatSlice.ts +interface ChatState { + conversations: ConversationSummary[]; // List of past conversations + activeConversationId: string | null; + messages: ChatMessage[]; // Messages for active conversation + isStreaming: boolean; // Currently receiving SSE response + streamingMessage: string; // Accumulated text during streaming + pendingToolCalls: ToolCallInfo[]; // Tool calls awaiting results + context: ConversationContext; // Current step, folderId, quizId, etc. + loading: boolean; + error: string | null; +} +``` + +--- + +## 4. Backend API Design + +### 4.1 Endpoints + +``` +POST /api/create/chat/conversations Create new conversation +GET /api/create/chat/conversations List user's conversations +GET /api/create/chat/conversations/:id Get conversation with messages +DELETE /api/create/chat/conversations/:id Delete conversation + +POST /api/create/chat/conversations/:id/messages Send message (SSE response) +``` + +### 4.2 SSE Event Types + +| Event | Payload | Description | +|-------|---------|-------------| +| `text_chunk` | `{ content: string }` | Streaming text fragment | +| `tool_call_start` | `{ toolCallId, name, arguments }` | LLM is calling a tool | +| `tool_call_result` | `{ toolCallId, name, result, status }` | Tool execution completed | +| `inline_ui` | `{ type, props }` | Render embedded UI component | +| `context_update` | `{ step, folderId?, quizId?, ... }` | Workflow context changed | +| `message_complete` | `{ messageId }` | Assistant message finished | +| `error` | `{ code, message }` | Error occurred | + +### 4.3 Request/Response Examples + +**Send Message:** +``` +POST /api/create/chat/conversations/:id/messages +Content-Type: application/json + +{ + "content": "I want to create a quiz about machine learning", + "inlineUIResponse": { // Optional: user's response to inline UI + "messageId": "msg_abc", + "selection": { "selectedIds": ["lo_1", "lo_3"] } + } +} + +Response: SSE stream (see event types above) +``` + +--- + +## 5. Function Calling (Tool Definitions) + +### 5.1 Tool Schema + +All tools follow OpenAI's function calling format. The system prompt instructs the LLM about the workflow steps and when to use each tool. + +```javascript +// 10 tools organized by workflow step +const CHAT_TOOLS = [ + // Step 1: Setup + { name: 'create_folder', description: 'Create a new course folder' }, + { name: 'list_folders', description: 'List user\'s existing folders' }, + { name: 'create_quiz', description: 'Create a new quiz in a folder' }, + + // Step 2: Materials + { name: 'add_material_url', description: 'Add learning material from a URL' }, + { name: 'list_materials', description: 'List materials in a folder' }, + { name: 'assign_materials', description: 'Assign materials to a quiz' }, + + // Step 3: Learning Objectives + { name: 'generate_objectives', description: 'Generate learning objectives from materials' }, + { name: 'save_objectives', description: 'Save selected learning objectives' }, + + // Step 4: Questions + { name: 'generate_plan', description: 'Generate a question distribution plan' }, + { name: 'generate_questions', description: 'Generate questions based on plan (streaming)' }, + + // Step 5: Export + { name: 'export_h5p', description: 'Export quiz as H5P package' }, +]; +``` + +See [Section 9: Tool Definitions Detail](#9-tool-definitions-detail) for full parameter schemas. + +### 5.2 System Prompt + +``` +You are a quiz creation assistant for TLEF-CREATE. Guide users through creating +educational quizzes step by step: + +1. SETUP: Help create or select a folder and quiz +2. MATERIALS: Help upload/select learning materials +3. OBJECTIVES: Generate and refine learning objectives +4. QUESTIONS: Generate questions from objectives +5. EXPORT: Export the quiz as H5P + +Rules: +- Always confirm before executing actions that create or modify data +- When presenting choices (objectives, question types), use the inline_ui + metadata to render interactive components in the chat +- For file uploads, instruct the user to drag files into the chat or paste URLs +- If the user wants to make detailed edits to multiple questions, suggest + switching to the Review tab in workflow mode +- Keep responses concise and educational +- Track progress in the context object +``` + +--- + +## 6. Frontend Component Architecture + +### 6.1 Component Tree + +``` +Header.tsx (modified — add toggle button) +│ +├── [Workflow Mode] QuizView.tsx (existing, unchanged) +│ +└── [Chat Mode] ChatMode.tsx + ├── ChatSidebar.tsx // Conversation history list + ├── ChatMessageList.tsx // Scrollable message area + │ └── ChatMessage.tsx (×N) // Individual message + │ ├── ChatToolResult.tsx // Tool call result display + │ └── ChatInlineAction.tsx // Embedded UI (checkboxes, buttons) + └── ChatInput.tsx // Text input + file drop zone +``` + +### 6.2 New Files + +| File | Lines (est.) | Responsibility | +|------|:---:|---| +| **Components** | | | +| `src/components/chat/ChatMode.tsx` | ~180 | Main chat container, layout, conversation management | +| `src/components/chat/ChatSidebar.tsx` | ~120 | Conversation history list, new/delete conversation | +| `src/components/chat/ChatMessageList.tsx` | ~100 | Auto-scrolling message list, loading states | +| `src/components/chat/ChatMessage.tsx` | ~150 | Single message bubble, renders text + tool results + inline UI | +| `src/components/chat/ChatToolResult.tsx` | ~200 | Renders tool call results as cards (folder created, LOs generated, etc.) | +| `src/components/chat/ChatInlineAction.tsx` | ~200 | Interactive components: checkbox lists, button groups, confirm buttons | +| `src/components/chat/ChatInput.tsx` | ~200 | Text input, file drop zone, URL paste detection, send button | +| **Hooks** | | | +| `src/hooks/useChatStream.ts` | ~180 | SSE connection for chat responses, event parsing, reconnection | +| `src/hooks/useChatActions.ts` | ~150 | Maps tool call results to Redux dispatches for workflow sync | +| **Redux** | | | +| `src/store/slices/chatSlice.ts` | ~250 | Chat state, async thunks for conversations, message management | +| **Services** | | | +| `src/services/chatApi.ts` | ~80 | Chat-specific API calls (extends api.ts pattern) | +| **Types** | | | +| `src/types/chat.ts` | ~100 | TypeScript interfaces for chat messages, tools, inline UI | +| **Styles** | | | +| `src/styles/components/chat/ChatMode.css` | ~150 | Chat layout, message bubbles, animations | + +### 6.3 Modified Files + +| File | Change | +|------|--------| +| `src/components/Header.tsx` | Add mode toggle button (Chat/Workflow) | +| `src/store/index.ts` | Add `chat: chatReducer` to store | +| `src/App.tsx` | Add mode state, conditional rendering of ChatMode vs QuizView | +| `src/services/api.ts` | Add `chatApi` section (~30 lines) OR use separate `chatApi.ts` | + +--- + +## 7. Backend File Architecture + +### 7.1 New Files + +| File | Lines (est.) | Responsibility | +|------|:---:|---| +| **Controller** | | | +| `routes/create/controllers/chatController.js` | ~200 | Express router: CRUD conversations, SSE message endpoint | +| **Services** | | | +| `routes/create/services/chatOrchestrationService.js` | ~280 | LLM conversation loop: send messages, handle tool calls, stream response | +| `routes/create/services/chatToolDefinitions.js` | ~200 | OpenAI tool schemas (all 11 tools with parameter definitions) | +| `routes/create/services/chatToolExecutor.js` | ~250 | Strategy pattern: maps tool names to service calls, executes, returns results | +| **Model** | | | +| `routes/create/models/Conversation.js` | ~120 | Mongoose schema for conversation persistence | +| **Config** | | | +| `routes/create/config/chatSystemPrompt.js` | ~80 | System prompt template with workflow instructions | + +### 7.2 Modified Files + +| File | Change | +|------|--------| +| `routes/create/createRoutes.js` | Mount `chatController` at `/chat` (~3 lines) | + +--- + +## 8. State Synchronization (Chat → Workflow) + +### 8.1 Sync Strategy + +When the chat backend executes a tool (e.g., `create_folder`), the SSE `tool_call_result` event contains the created resource data. The frontend `useChatActions` hook intercepts these results and dispatches the corresponding Redux actions: + +```typescript +// useChatActions.ts — mapping table +const TOOL_DISPATCH_MAP: Record void> = { + create_folder: (r, d) => d(addQuizLocally(r.folder)), // or navigate + create_quiz: (r, d) => d(addQuizLocally(r.quiz)), + add_material_url: (r, d) => d(addMaterialLocally(r.material)), + assign_materials: (r, d) => d(assignMaterials.fulfilled(r)), + generate_objectives: (r, d) => d(setObjectivesFromChat(r.objectives)), + save_objectives: (r, d) => d(saveObjectives.fulfilled(r)), + generate_plan: (r, d) => d(setCurrentPlan(r.plan)), + generate_questions: (r, d) => d(setQuestionsForQuiz(r)), + export_h5p: (r, d) => { /* trigger download */ }, +}; +``` + +### 8.2 Context Tracking + +The backend tracks workflow progress in `conversation.context`: + +```javascript +// After each tool call, update context +context: { + step: 'objectives', // Current workflow stage + folderId: '507f1f77...', // Created/selected folder + quizId: '507f1f77...', // Created/selected quiz + materialIds: ['...'], // Uploaded/selected materials + objectiveIds: ['...'], // Generated/saved objectives + planId: '507f1f77...' // Generated plan +} +``` + +This context is injected into each LLM call so the AI knows what's been done and what comes next. + +### 8.3 Navigation Sync + +When user switches from Chat Mode back to Workflow Mode: +1. Read `chatSlice.context` (folderId, quizId) +2. Navigate to `/course/:folderId/quiz/:quizId` +3. Redux store already has the data from chat actions +4. Workflow tabs reflect all work done in chat + +--- + +## 9. Tool Definitions Detail + +### 9.1 Setup Tools + +```javascript +{ + name: 'create_folder', + description: 'Create a new course folder for organizing quizzes', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Folder name (e.g. course name)' }, + description: { type: 'string', description: 'Optional folder description' } + }, + required: ['name'] + } +} + +{ + name: 'list_folders', + description: 'List all folders owned by the current user', + parameters: { type: 'object', properties: {} } +} + +{ + name: 'create_quiz', + description: 'Create a new quiz within a folder', + parameters: { + type: 'object', + properties: { + folderId: { type: 'string', description: 'Folder ID to create quiz in' }, + title: { type: 'string', description: 'Quiz title' } + }, + required: ['folderId', 'title'] + } +} +``` + +### 9.2 Material Tools + +```javascript +{ + name: 'add_material_url', + description: 'Add learning material from a URL. The system will fetch and process the content.', + parameters: { + type: 'object', + properties: { + folderId: { type: 'string', description: 'Folder to add material to' }, + url: { type: 'string', description: 'URL of the learning material' }, + title: { type: 'string', description: 'Optional title for the material' } + }, + required: ['folderId', 'url'] + } +} + +{ + name: 'list_materials', + description: 'List all materials in a folder', + parameters: { + type: 'object', + properties: { + folderId: { type: 'string', description: 'Folder ID' } + }, + required: ['folderId'] + } +} + +{ + name: 'assign_materials', + description: 'Assign selected materials to a quiz for question generation', + parameters: { + type: 'object', + properties: { + quizId: { type: 'string', description: 'Quiz ID' }, + materialIds: { type: 'array', items: { type: 'string' }, description: 'Material IDs to assign' } + }, + required: ['quizId', 'materialIds'] + } +} +``` + +### 9.3 Objective Tools + +```javascript +{ + name: 'generate_objectives', + description: 'Generate learning objectives from assigned materials using AI. Returns a list for user to review and select.', + parameters: { + type: 'object', + properties: { + quizId: { type: 'string', description: 'Quiz ID with assigned materials' }, + count: { type: 'number', description: 'Number of objectives to generate (default 5)' }, + approach: { type: 'string', enum: ['support', 'challenge', 'balanced'], description: 'Pedagogical approach' } + }, + required: ['quizId'] + } +} + +{ + name: 'save_objectives', + description: 'Save the selected learning objectives for question generation', + parameters: { + type: 'object', + properties: { + quizId: { type: 'string', description: 'Quiz ID' }, + objectiveIds: { type: 'array', items: { type: 'string' }, description: 'Selected objective IDs to keep' } + }, + required: ['quizId', 'objectiveIds'] + } +} +``` + +### 9.4 Question Tools + +```javascript +{ + name: 'generate_plan', + description: 'Generate a question distribution plan specifying question types and counts per objective', + parameters: { + type: 'object', + properties: { + quizId: { type: 'string', description: 'Quiz ID' }, + totalQuestions: { type: 'number', description: 'Total questions to generate (default 10)' }, + questionTypes: { type: 'array', items: { type: 'string', enum: ['mc', 'tf', 'matching', 'ordering', 'cloze', 'sa', 'essay', 'flashcard'] }, description: 'Preferred question types' } + }, + required: ['quizId'] + } +} + +{ + name: 'generate_questions', + description: 'Generate questions based on the approved plan. This is a long-running operation that streams progress.', + parameters: { + type: 'object', + properties: { + quizId: { type: 'string', description: 'Quiz ID' }, + planId: { type: 'string', description: 'Approved plan ID' } + }, + required: ['quizId', 'planId'] + } +} +``` + +### 9.5 Export Tools + +```javascript +{ + name: 'export_h5p', + description: 'Export the quiz as an H5P interactive content package for download', + parameters: { + type: 'object', + properties: { + quizId: { type: 'string', description: 'Quiz ID to export' }, + format: { type: 'string', enum: ['h5p', 'pdf'], description: 'Export format (default h5p)' } + }, + required: ['quizId'] + } +} +``` + +--- + +## 10. Inline UI Components + +### 10.1 Component Types + +The `ChatInlineAction` component renders different UI types based on `metadata.inlineUI.type`: + +| Type | When Used | Rendered As | +|------|-----------|-------------| +| `checkbox-list` | Select LOs, select materials, select question types | Checkboxes with labels + Confirm button | +| `button-group` | Quick choices (yes/no, number of questions, approach) | Row of buttons | +| `file-upload` | Material upload step | Drop zone + URL paste field | +| `question-preview` | After question generation | Collapsed question cards with expand | +| `plan-summary` | After plan generation | Table showing type × objective distribution | +| `confirm` | Before executing destructive/important actions | Confirm / Cancel buttons | + +### 10.2 Inline UI Flow + +1. Backend LLM decides to present choices → includes `inline_ui` in SSE metadata +2. Frontend `ChatMessage` renders `ChatInlineAction` with the spec +3. User interacts (checks boxes, clicks button) +4. User's selection is sent in the next `POST /chat/conversations/:id/messages` as `inlineUIResponse` +5. Backend receives selection, may execute tool call, continues conversation + +### 10.3 Modal Escalation + +When the AI determines the operation is too complex for inline UI (e.g., editing question content), it sends a `inline_ui` with type `modal-trigger`: + +```json +{ + "type": "modal-trigger", + "props": { + "label": "Edit Questions in Detail", + "modalComponent": "QuestionEditModal", + "data": { "quizId": "...", "questionIds": ["..."] } + } +} +``` + +The frontend renders a button that, when clicked, opens the appropriate modal (reusing existing modal components). + +### 10.4 Workflow Switch Suggestion + +For bulk editing, the AI suggests switching: + +```json +{ + "type": "workflow-switch", + "props": { + "label": "Switch to Review Tab", + "targetTab": "review", + "folderId": "...", + "quizId": "..." + } +} +``` + +Frontend renders a styled button that navigates to the workflow view at the correct tab. + +--- + +## 11. File Upload in Chat + +### 11.1 User Experience + +The `ChatInput` component supports: + +1. **Drag & Drop**: User drags files onto the chat input area + - Shows a visual drop zone overlay + - Accepts: PDF, DOCX, TXT, PPTX + - File is uploaded immediately via existing `materialsApi.uploadFile()` + +2. **URL Paste**: User pastes a URL in the text input + - Auto-detected via regex pattern + - Shown as a chip/pill above the input + - Sent as part of the message; backend calls `add_material_url` tool + +3. **Text Paste**: Long text is treated as text material + - If message exceeds a threshold (e.g., 500 chars), offer to save as text material + +### 11.2 Upload Flow + +``` +User drops file → ChatInput shows file preview chip +User sends message → Frontend uploads file via materialsApi.uploadFile() + → After upload success, sends chat message with materialId reference + → Backend LLM acknowledges upload, continues workflow + → useChatActions dispatches addMaterialLocally() +``` + +--- + +## 12. Error Handling + +### 12.1 LLM Errors +- Timeout: After 60s with no response, show retry button in chat +- Rate limit: Show "Please wait" with cooldown timer +- Invalid tool call: Backend catches, returns error result to LLM, LLM self-corrects + +### 12.2 Tool Execution Errors +- Backend wraps each tool execution in try/catch +- Error result returned to LLM as tool result with `status: 'error'` +- LLM acknowledges the error and suggests alternatives +- Frontend shows error as a distinct message style (red border) + +### 12.3 SSE Connection Errors +- Reuse `useSSE` reconnection pattern (exponential backoff, max 5 attempts) +- On permanent failure, show "Connection lost" with manual reconnect button +- Messages already received are preserved in Redux state + +--- + +## 13. Implementation Phases + +### Phase 1: Foundation (Core Chat Loop) +**Goal**: User can chat with AI, AI can call tools, basic text conversation works. + +**Backend:** +- [ ] `Conversation` model +- [ ] `chatController.js` — CRUD + SSE message endpoint +- [ ] `chatOrchestrationService.js` — OpenAI integration with tool calling loop +- [ ] `chatToolDefinitions.js` — All tool schemas +- [ ] `chatToolExecutor.js` — Execute `create_folder`, `list_folders`, `create_quiz` only +- [ ] `chatSystemPrompt.js` — System prompt +- [ ] Mount in `createRoutes.js` + +**Frontend:** +- [ ] `chat.ts` types +- [ ] `chatSlice.ts` — State management +- [ ] `chatApi.ts` — API calls +- [ ] `useChatStream.ts` — SSE hook +- [ ] `ChatMode.tsx` — Main container +- [ ] `ChatMessageList.tsx` + `ChatMessage.tsx` — Message rendering +- [ ] `ChatInput.tsx` — Text input (no file upload yet) +- [ ] `ChatToolResult.tsx` — Basic tool result cards +- [ ] Toggle button in `Header.tsx` +- [ ] Wire up in `App.tsx` + +**Sync:** +- [ ] `useChatActions.ts` — Dispatch for create_folder, create_quiz + +### Phase 2: Full Workflow (Materials + Objectives + Questions) +**Goal**: Complete workflow through chat with inline UI and state sync. + +**Backend:** +- [ ] Implement remaining tools in `chatToolExecutor.js`: materials, objectives, plan, questions +- [ ] Handle `generate_questions` streaming within chat SSE (nested streaming) +- [ ] Inline UI metadata generation in orchestration service + +**Frontend:** +- [ ] `ChatInlineAction.tsx` — Checkbox lists, button groups, confirm +- [ ] `ChatInput.tsx` — Add file drag & drop, URL paste detection +- [ ] `useChatActions.ts` — All remaining Redux sync mappings +- [ ] `ChatSidebar.tsx` — Conversation history +- [ ] Chat → Workflow navigation sync (switch back shows correct state) + +### Phase 3: Polish & Export +**Goal**: Export support, modal escalation, edge cases. + +**Backend:** +- [ ] Implement export tools in executor +- [ ] Conversation title auto-generation (LLM summarize) +- [ ] Conversation archiving / cleanup + +**Frontend:** +- [ ] Modal escalation (question editing modal triggered from chat) +- [ ] Workflow switch suggestion button +- [ ] `ChatMode.css` — Animations, responsive design +- [ ] Question preview inline component +- [ ] Plan summary inline component +- [ ] Error states and retry UI +- [ ] Loading skeletons during streaming +- [ ] Mobile responsive chat layout + +--- + +## 14. Testing Strategy + +### 14.1 Backend Tests + +| Test File | Type | Coverage | +|-----------|------|----------| +| `__tests__/unit/chatToolExecutor.test.js` | Unit | Tool execution strategies, error handling | +| `__tests__/unit/chatToolDefinitions.test.js` | Unit | Schema validation | +| `__tests__/integration/chat.test.js` | Integration | Full conversation flow, SSE events, persistence | + +### 14.2 Frontend Tests + +| Test File | Type | Coverage | +|-----------|------|----------| +| `src/components/chat/__tests__/ChatMessage.test.tsx` | Component | Renders text, tool results, inline UI | +| `src/components/chat/__tests__/ChatInput.test.tsx` | Component | Send message, file drop, URL detection | +| `src/components/chat/__tests__/ChatInlineAction.test.tsx` | Component | User interactions, selection callbacks | +| `src/hooks/__tests__/useChatStream.test.ts` | Hook | SSE parsing, reconnection, event mapping | +| `src/hooks/__tests__/useChatActions.test.ts` | Hook | Redux dispatch mapping correctness | + +--- + +## 15. Security Considerations + +- **Authentication**: Chat endpoints use existing `authenticateToken` middleware +- **Authorization**: Conversations are scoped to `user` field; all tool executions verify resource ownership +- **Input Sanitization**: User messages sanitized before LLM prompt injection + - Strip system-prompt-like patterns + - Limit message length (e.g., 4000 chars) +- **Rate Limiting**: Chat message endpoint rate-limited (e.g., 20 messages/minute) +- **Tool Execution**: All tools execute through existing service layer which already validates permissions +- **File Upload**: Reuses existing Multer validation (file type, size limits) + +--- + +## 16. Performance Considerations + +- **Conversation History Truncation**: Only send last N messages (e.g., 20) + system prompt to LLM to stay within token limits. Older messages summarized. +- **SSE Keep-Alive**: Heartbeat every 15s to prevent proxy timeouts +- **Lazy Loading**: Chat components code-split with `React.lazy()` — only loaded when Chat Mode activated +- **Message Pagination**: Load older messages on scroll-up (not all at once) +- **Debounced Input**: URL detection regex runs on debounced input (300ms) + +--- + +## Appendix A: File Inventory + +### New Files (17 files) + +``` +# Frontend (13 files) +src/types/chat.ts ~100 lines +src/store/slices/chatSlice.ts ~250 lines +src/services/chatApi.ts ~80 lines +src/hooks/useChatStream.ts ~180 lines +src/hooks/useChatActions.ts ~150 lines +src/components/chat/ChatMode.tsx ~180 lines +src/components/chat/ChatSidebar.tsx ~120 lines +src/components/chat/ChatMessageList.tsx ~100 lines +src/components/chat/ChatMessage.tsx ~150 lines +src/components/chat/ChatToolResult.tsx ~200 lines +src/components/chat/ChatInlineAction.tsx ~200 lines +src/components/chat/ChatInput.tsx ~200 lines +src/styles/components/chat/ChatMode.css ~150 lines + +# Backend (6 files) +routes/create/models/Conversation.js ~120 lines +routes/create/controllers/chatController.js ~200 lines +routes/create/services/chatOrchestrationService.js ~280 lines +routes/create/services/chatToolDefinitions.js ~200 lines +routes/create/services/chatToolExecutor.js ~250 lines +routes/create/config/chatSystemPrompt.js ~80 lines +``` + +### Modified Files (4 files) + +``` +src/components/Header.tsx +20 lines (toggle button) +src/store/index.ts +3 lines (add chat reducer) +src/App.tsx +15 lines (mode routing) +routes/create/createRoutes.js +3 lines (mount chat controller) +``` + +**Total estimated new code**: ~2,990 lines across 17 new files (avg ~176 lines/file) +**Max file size**: 280 lines (chatOrchestrationService.js) diff --git a/package-lock.json b/package-lock.json index 2fed45d..271850a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^3.9.0", "@reduxjs/toolkit": "^2.8.2", "@tanstack/react-query": "^5.56.2", + "adm-zip": "^0.5.16", "agenda": "^5.0.0", "archiver": "^7.0.1", "bcryptjs": "^3.0.2", @@ -6585,6 +6586,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agenda": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/agenda/-/agenda-5.0.0.tgz", diff --git a/package.json b/package.json index 9975574..7510f8d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@hookform/resolvers": "^3.9.0", "@reduxjs/toolkit": "^2.8.2", "@tanstack/react-query": "^5.56.2", + "adm-zip": "^0.5.16", "agenda": "^5.0.0", "archiver": "^7.0.1", "bcryptjs": "^3.0.2", diff --git a/routes/create/controllers/h5pPreviewController.js b/routes/create/controllers/h5pPreviewController.js new file mode 100644 index 0000000..e2c7e09 --- /dev/null +++ b/routes/create/controllers/h5pPreviewController.js @@ -0,0 +1,404 @@ +import express from 'express'; +import multer from 'multer'; +import AdmZip from 'adm-zip'; +import path from 'path'; +import fs from 'fs/promises'; +import { fileURLToPath } from 'url'; +import { v4 as uuidv4 } from 'uuid'; +import { successResponse, errorResponse } from '../utils/responseFormatter.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { HTTP_STATUS } from '../config/constants.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const router = express.Router(); + +// Upload directory for extracted H5P previews +const UPLOAD_BASE = path.join(__dirname, '..', 'uploads', 'h5p-preview'); +const H5P_LIBS_DIR = path.join(__dirname, '..', 'h5p-libs'); +const MAX_AGE_MS = 60 * 60 * 1000; // 1 hour TTL for extracted files + +// Configure multer for .h5p file uploads (in-memory, max 50MB) +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 50 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (file.originalname.endsWith('.h5p') || file.mimetype === 'application/zip') { + cb(null, true); + } else { + cb(new Error('Only .h5p files are allowed')); + } + } +}); + +/** + * POST /upload — Accept .h5p file, extract, return metadata + */ +router.post('/upload', upload.single('h5pFile'), asyncHandler(async (req, res) => { + if (!req.file) { + return errorResponse(res, 'No .h5p file provided', 'NO_FILE', HTTP_STATUS.BAD_REQUEST); + } + + const id = uuidv4(); + const extractDir = path.join(UPLOAD_BASE, id); + + // Ensure upload directory exists + await fs.mkdir(extractDir, { recursive: true }); + + // Extract the .h5p ZIP + const zip = new AdmZip(req.file.buffer); + zip.extractAllTo(extractDir, true); + + // Parse h5p.json + const h5pJsonPath = path.join(extractDir, 'h5p.json'); + let h5pJson; + try { + const raw = await fs.readFile(h5pJsonPath, 'utf-8'); + h5pJson = JSON.parse(raw); + } catch (e) { + // Clean up on failure + await fs.rm(extractDir, { recursive: true, force: true }); + return errorResponse(res, 'Invalid .h5p file: missing or malformed h5p.json', 'INVALID_H5P', HTTP_STATUS.BAD_REQUEST); + } + + // Run cleanup of old extracted dirs (fire-and-forget) + cleanupOldPreviews().catch(() => {}); + + return successResponse(res, { + id, + title: h5pJson.title || 'Untitled', + mainLibrary: h5pJson.mainLibrary, + preloadedDependencies: h5pJson.preloadedDependencies || [] + }, 'H5P file uploaded and extracted'); +})); + +/** + * GET /core/h5p-core.js — Serve the minimal H5P runtime + */ +router.get('/core/h5p-core.js', asyncHandler(async (req, res) => { + const corePath = path.join(__dirname, '..', 'h5p-core', 'h5p-core.js'); + res.type('application/javascript').sendFile(corePath); +})); + +/** + * GET /:id/render — Generate and serve the full HTML page for rendering H5P content + */ +router.get('/:id/render', asyncHandler(async (req, res) => { + const { id } = req.params; + const extractDir = path.join(UPLOAD_BASE, id); + + // Verify the extracted directory exists + try { + await fs.access(extractDir); + } catch { + return errorResponse(res, 'Preview not found. It may have expired.', 'NOT_FOUND', HTTP_STATUS.NOT_FOUND); + } + + // Read h5p.json + const h5pJson = JSON.parse(await fs.readFile(path.join(extractDir, 'h5p.json'), 'utf-8')); + + // Read content.json + let contentJson; + try { + contentJson = JSON.parse(await fs.readFile(path.join(extractDir, 'content', 'content.json'), 'utf-8')); + } catch { + return errorResponse(res, 'Missing content/content.json in H5P package', 'INVALID_H5P', HTTP_STATUS.BAD_REQUEST); + } + + // Resolve all dependencies (topological sort) + const { cssFiles, jsFiles } = await resolveDependencies(h5pJson, extractDir); + + // Build the base path for static files + const basePath = `/h5p-preview-files/${id}`; + + // Build the main library string "H5P.MultiChoice 1.16" + const mainLib = h5pJson.mainLibrary; + const mainDep = (h5pJson.preloadedDependencies || []).find(d => d.machineName === mainLib); + const mainLibString = mainDep + ? `${mainLib} ${mainDep.majorVersion}.${mainDep.minorVersion}` + : mainLib; + + // Generate CSS link tags + const cssTags = cssFiles.map(f => ` `).join('\n'); + + // Generate JS script tags + const jsTags = jsFiles.map(f => ` `).join('\n'); + + const html = ` + + + + + ${escapeHtml(h5pJson.title || 'H5P Preview')} + +${cssTags} + + +
+ + + +${jsTags} + + + +`; + + // Override Helmet's CSP to allow framing and inline scripts/CDN resources + res.removeHeader('Content-Security-Policy'); + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + res.type('text/html').send(html); +})); + +/** + * Resolve the full dependency tree from h5p.json into ordered CSS and JS file lists. + * Uses topological sort (Kahn's algorithm) to ensure correct load order. + */ +async function resolveDependencies(h5pJson, extractDir) { + const deps = h5pJson.preloadedDependencies || []; + + // Map: "machineName-major.minor" → { dirName, css[], js[], deps[] } + const libMap = new Map(); + const adjacency = new Map(); // key → [dependency keys] + const inDegree = new Map(); + + // BFS to discover all libraries and their transitive dependencies + const queue = [...deps]; + const visited = new Set(); + + while (queue.length > 0) { + const dep = queue.shift(); + const key = `${dep.machineName}-${dep.majorVersion}.${dep.minorVersion}`; + if (visited.has(key)) continue; + visited.add(key); + + // Find the library directory — could be in extracted H5P or in h5p-libs + const dirName = `${dep.machineName}-${dep.majorVersion}.${dep.minorVersion}`; + let libJsonPath = path.join(extractDir, dirName, 'library.json'); + let libBasePath = dirName; // relative path for URL generation + let libDirExists = false; + + try { + await fs.access(libJsonPath); + libDirExists = true; + } catch { + // Try the shared h5p-libs directory + libJsonPath = path.join(H5P_LIBS_DIR, dirName, 'library.json'); + try { + await fs.access(libJsonPath); + libDirExists = true; + } catch { + // Library not found — skip + } + } + + if (!libDirExists) { + libMap.set(key, { dirName, css: [], js: [], deps: [] }); + adjacency.set(key, []); + inDegree.set(key, inDegree.get(key) || 0); + continue; + } + + const libJson = JSON.parse(await fs.readFile(libJsonPath, 'utf-8')); + + // Merge from shared h5p-libs into extracted dir so static serving works. + // Always merge (not just when missing) because the .h5p archive may contain + // incomplete library dirs (e.g. metadata only, no dist/ build artifacts). + const extractedLibDir = path.join(extractDir, dirName); + const sharedLibDir = path.join(H5P_LIBS_DIR, dirName); + try { + await fs.access(sharedLibDir); + await mergeDir(sharedLibDir, extractedLibDir); + } catch { + // Shared lib not available, rely on whatever's in the archive + } + + const css = (libJson.preloadedCss || []).map(f => `${dirName}/${f.path}`); + const js = (libJson.preloadedJs || []).map(f => `${dirName}/${f.path}`); + const subDeps = libJson.preloadedDependencies || []; + const subDepKeys = subDeps.map(d => `${d.machineName}-${d.majorVersion}.${d.minorVersion}`); + + libMap.set(key, { dirName, css, js, deps: subDepKeys }); + adjacency.set(key, subDepKeys); + + if (!inDegree.has(key)) { + inDegree.set(key, 0); + } + + // Enqueue sub-dependencies + for (const subDep of subDeps) { + queue.push(subDep); + } + } + + // Build in-degree counts + for (const [key, depKeys] of adjacency) { + for (const depKey of depKeys) { + inDegree.set(depKey, (inDegree.get(depKey) || 0)); + } + } + // A depends on B means B must load before A → A has edge to B + // In-degree: count how many things depend on each lib (incoming edges) + // Actually, for topological sort with Kahn's, we need: if A depends on B, then B must come first. + // So the edge is B → A (B must come before A), and A's in-degree increases. + const reverseAdj = new Map(); + const realInDegree = new Map(); + for (const key of adjacency.keys()) { + reverseAdj.set(key, []); + realInDegree.set(key, 0); + } + for (const [key, depKeys] of adjacency) { + for (const depKey of depKeys) { + if (!reverseAdj.has(depKey)) reverseAdj.set(depKey, []); + reverseAdj.get(depKey).push(key); + realInDegree.set(key, (realInDegree.get(key) || 0) + 1); + } + } + + // Kahn's algorithm + const sorted = []; + const q = []; + for (const [key, deg] of realInDegree) { + if (deg === 0) q.push(key); + } + + while (q.length > 0) { + const current = q.shift(); + sorted.push(current); + for (const neighbor of (reverseAdj.get(current) || [])) { + realInDegree.set(neighbor, realInDegree.get(neighbor) - 1); + if (realInDegree.get(neighbor) === 0) { + q.push(neighbor); + } + } + } + + // If there are nodes not in sorted (cycle), add them at the end + for (const key of adjacency.keys()) { + if (!sorted.includes(key)) { + sorted.push(key); + } + } + + // Collect CSS and JS in dependency order, filtering out files that don't exist on disk + const cssFiles = []; + const jsFiles = []; + for (const key of sorted) { + const lib = libMap.get(key); + if (lib) { + for (const f of lib.css) { + const fullPath = path.join(extractDir, f); + try { + await fs.access(fullPath); + cssFiles.push(f); + } catch { + // File doesn't exist (e.g. missing dist/ build), skip it + } + } + for (const f of lib.js) { + const fullPath = path.join(extractDir, f); + try { + await fs.access(fullPath); + jsFiles.push(f); + } catch { + // File doesn't exist (e.g. missing dist/ build), skip it + } + } + } + } + + return { cssFiles, jsFiles }; +} + +/** + * Recursively merge src into dest — copies files that don't already exist in dest. + * This fills in missing build artifacts (dist/) without overwriting archive contents. + */ +async function mergeDir(src, dest) { + await fs.mkdir(dest, { recursive: true }); + const entries = await fs.readdir(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + await mergeDir(srcPath, destPath); + } else { + try { + await fs.access(destPath); + // File already exists in archive, skip + } catch { + await fs.copyFile(srcPath, destPath); + } + } + } +} + +/** + * Clean up extracted preview directories older than MAX_AGE_MS + */ +async function cleanupOldPreviews() { + try { + await fs.access(UPLOAD_BASE); + } catch { + return; // Directory doesn't exist yet + } + + const entries = await fs.readdir(UPLOAD_BASE, { withFileTypes: true }); + const now = Date.now(); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dirPath = path.join(UPLOAD_BASE, entry.name); + try { + const stat = await fs.stat(dirPath); + if (now - stat.mtimeMs > MAX_AGE_MS) { + await fs.rm(dirPath, { recursive: true, force: true }); + } + } catch { + // Ignore errors during cleanup + } + } +} + +/** + * Simple HTML escape + */ +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export default router; diff --git a/routes/create/createRoutes.js b/routes/create/createRoutes.js index 504566f..653d491 100644 --- a/routes/create/createRoutes.js +++ b/routes/create/createRoutes.js @@ -15,6 +15,7 @@ import questionController from './controllers/questionController.js'; import exportController from './controllers/exportController.js'; import streamingController from './controllers/streamingController.js'; import searchController from './controllers/searchController.js'; +import h5pPreviewController from './controllers/h5pPreviewController.js'; const router = express.Router(); @@ -71,8 +72,8 @@ router.use('/auth/saml/login', authLimiter); router.use('/materials/upload', uploadLimiter); // Apply API rate limiting to all routes except config and streaming endpoints router.use((req, res, next) => { - if (req.path === '/auth/config' || req.path.startsWith('/streaming/')) { - return next(); // Skip rate limiting for config and streaming endpoints + if (req.path === '/auth/config' || req.path.startsWith('/streaming/') || req.path.startsWith('/h5p-preview/')) { + return next(); // Skip rate limiting for config, streaming, and h5p-preview endpoints } return apiLimiter(req, res, next); }); @@ -98,6 +99,7 @@ router.use('/questions', questionController); router.use('/export', exportController); router.use('/streaming', streamingController); router.use('/search', searchController); +router.use('/h5p-preview', h5pPreviewController); // DEBUG: Route to log all chunks in Qdrant (for debugging purposes) router.get('/debug/qdrant-chunks', async (req, res) => { diff --git a/routes/create/h5p-core/h5p-core.js b/routes/create/h5p-core/h5p-core.js new file mode 100644 index 0000000..9fc29b5 --- /dev/null +++ b/routes/create/h5p-core/h5p-core.js @@ -0,0 +1,738 @@ +/** + * Minimal H5P Core Runtime + * + * Provides the global H5P object that all H5P content type libraries depend on. + * This is a stripped-down version of the official h5p.js runtime, containing only + * the pieces needed to render content (no editor, no server communication). + */ + +var H5P = H5P || {}; + +/** + * EventDispatcher — base class that all H5P content types extend. + * Provides on/off/once/trigger event system. + */ +H5P.EventDispatcher = (function () { + /** + * @class + */ + function EventDispatcher() { + this.listeners = {}; + } + + EventDispatcher.prototype.on = function (type, listener, thisArg) { + if (typeof listener === 'function') { + if (!this.listeners[type]) { + this.listeners[type] = []; + } + this.listeners[type].push({ fn: listener, thisArg: thisArg }); + } + return this; + }; + + EventDispatcher.prototype.once = function (type, listener, thisArg) { + if (typeof listener === 'function') { + var self = this; + var wrapper = function () { + self.off(type, wrapper); + listener.apply(this, arguments); + }; + wrapper._original = listener; + this.on(type, wrapper, thisArg); + } + return this; + }; + + EventDispatcher.prototype.off = function (type, listener) { + if (this.listeners[type]) { + if (listener) { + this.listeners[type] = this.listeners[type].filter(function (l) { + return l.fn !== listener && l.fn._original !== listener; + }); + } else { + this.listeners[type] = []; + } + } + }; + + EventDispatcher.prototype.trigger = function (event, extra, eventData) { + if (typeof event === 'string') { + event = new H5P.Event(event, extra, eventData); + } + event.type = event.type || 'unknown'; + if (this.listeners[event.type]) { + var listeners = this.listeners[event.type].slice(); + for (var i = 0; i < listeners.length; i++) { + listeners[i].fn.call(listeners[i].thisArg || this, event); + } + } + + // Bubble xAPI events up through parent chain + if (event.type === 'xAPI' && !event.preventBubbling && this.parent) { + if (this.parent.trigger) { + this.parent.trigger(event); + } + } + + // Propagate xAPI events to external dispatcher (but not from the dispatcher itself) + if (event.type === 'xAPI' && H5P.externalDispatcher && this !== H5P.externalDispatcher) { + H5P.externalDispatcher.trigger(event); + } + }; + + // Content type API methods — the official H5P runtime provides these on all instances. + // H5P libraries (Column, QuestionSet, etc.) call these in their constructors. + EventDispatcher.prototype.setActivityStarted = function () {}; + EventDispatcher.prototype.getScore = function () { return 0; }; + EventDispatcher.prototype.getMaxScore = function () { return 0; }; + EventDispatcher.prototype.getTitle = function () { return ''; }; + EventDispatcher.prototype.getAnswerGiven = function () { return false; }; + EventDispatcher.prototype.showSolutions = function () {}; + EventDispatcher.prototype.resetTask = function () {}; + EventDispatcher.prototype.getXAPIData = function () { return { statement: {} }; }; + EventDispatcher.prototype.getCurrentState = function () { return {}; }; + EventDispatcher.prototype.isRoot = function () { return false; }; + + // xAPI instance methods — libraries call these on `this` (not the static H5P.createXAPIEventTemplate) + EventDispatcher.prototype.createXAPIEventTemplate = function (verb, extra) { + var event = H5P.createXAPIEventTemplate(verb, extra); + event.setObject(this); + if (this.parent) { + event.setContext(this); + } + return event; + }; + + EventDispatcher.prototype.triggerXAPI = function (verb, extra) { + var event = this.createXAPIEventTemplate(verb, extra); + this.trigger(event); + return event; + }; + + EventDispatcher.prototype.triggerXAPIScored = function (score, maxScore, verb, completion, success) { + var event = this.createXAPIEventTemplate(verb || 'answered'); + event.setScoredResult(score, maxScore, this, completion, success); + this.trigger(event); + return event; + }; + + EventDispatcher.prototype.triggerXAPICompleted = function (score, maxScore, success) { + var event = this.createXAPIEventTemplate('completed'); + event.setScoredResult(score, maxScore, this, true, success); + this.trigger(event); + return event; + }; + + return EventDispatcher; +})(); + +/** + * H5P.Event + */ +H5P.Event = function (type, data, extras) { + this.type = type; + this.data = data || {}; + this.extras = extras || {}; + this.preventBubbling = false; + this.scheduledForLater = false; + + this.setBubbling = function (val) { + this.preventBubbling = !val; + }; + + this.getBubbling = function () { + return !this.preventBubbling; + }; + + this.preventDefault = function () { + this.defaultPrevented = true; + }; + + this.getScore = function () { + return this.data.statement && this.data.statement.result + ? this.data.statement.result.score && this.data.statement.result.score.raw + : null; + }; + + this.getMaxScore = function () { + return this.data.statement && this.data.statement.result + ? this.data.statement.result.score && this.data.statement.result.score.max + : null; + }; + + this.getVerifiedStatementValue = function (keys) { + var val = this.data.statement; + for (var i = 0; i < keys.length; i++) { + if (val === undefined || val === null) return null; + val = val[keys[i]]; + } + return val; + }; +}; + +/** + * XAPIEvent — wrapper for xAPI statements + */ +H5P.XAPIEvent = function () { + H5P.Event.call(this, 'xAPI', { statement: {} }, { bubbles: true, external: true }); +}; + +H5P.XAPIEvent.prototype = Object.create(H5P.Event.prototype); +H5P.XAPIEvent.prototype.constructor = H5P.XAPIEvent; + +H5P.XAPIEvent.prototype.setScoredResult = function (score, maxScore, instance, completion, success) { + this.data.statement.result = this.data.statement.result || {}; + this.data.statement.result.score = { + min: 0, + raw: score, + max: maxScore, + scaled: maxScore > 0 ? score / maxScore : 0 + }; + if (typeof completion === 'boolean') { + this.data.statement.result.completion = completion; + } + if (typeof success === 'boolean') { + this.data.statement.result.success = success; + } +}; + +H5P.XAPIEvent.prototype.setVerb = function (verb) { + if (typeof verb === 'string') { + if (verb.indexOf('http') !== 0) { + verb = 'http://adlnet.gov/expapi/verbs/' + verb; + } + this.data.statement.verb = { + id: verb, + display: { 'en-US': verb.split('/').pop() } + }; + } else if (typeof verb === 'object') { + this.data.statement.verb = verb; + } +}; + +H5P.XAPIEvent.prototype.getVerb = function (full) { + var statement = this.data.statement; + if (statement && statement.verb) { + if (full) return statement.verb; + return statement.verb.id ? statement.verb.id.split('/').pop() : ''; + } + return null; +}; + +H5P.XAPIEvent.prototype.setObject = function (instance) { + if (instance && instance.contentId) { + this.data.statement.object = { + id: 'h5p-content-' + instance.contentId, + objectType: 'Activity' + }; + } +}; + +H5P.XAPIEvent.prototype.setContext = function (instance) { + if (instance && instance.parent) { + this.data.statement.context = { + contextActivities: { + parent: [{ id: 'h5p-content-' + instance.parent.contentId, objectType: 'Activity' }] + } + }; + } +}; + +H5P.XAPIEvent.prototype.setActor = function () { + this.data.statement.actor = { + account: { name: 'preview-user', homePage: window.location.origin }, + objectType: 'Agent' + }; +}; + +H5P.XAPIEvent.prototype.getScore = function () { + return this.getVerifiedStatementValue(['result', 'score', 'raw']); +}; + +H5P.XAPIEvent.prototype.getMaxScore = function () { + return this.getVerifiedStatementValue(['result', 'score', 'max']); +}; + +H5P.XAPIEvent.prototype.getContentXAPIId = function (instance) { + if (instance && instance.contentId) { + return 'h5p-content-' + instance.contentId; + } + return null; +}; + +/** + * Create an xAPI event template + */ +H5P.createXAPIEventTemplate = function (verb, extra) { + var event = new H5P.XAPIEvent(); + event.setActor(); + event.setVerb(verb); + if (extra) { + for (var key in extra) { + if (extra.hasOwnProperty(key)) { + event.data.statement[key] = extra[key]; + } + } + } + return event; +}; + +/** + * External event dispatcher — singleton for bubbled xAPI events + */ +H5P.externalDispatcher = new H5P.EventDispatcher(); + +/** + * jQuery reference — set after jQuery loads + */ +H5P.jQuery = (typeof jQuery !== 'undefined') ? jQuery : (typeof $ !== 'undefined' ? $ : null); + +/** + * Global state + */ +H5P.isFramed = (window.self !== window.top); +H5P.instances = []; +H5P.contentDatas = {}; + +/** + * Resolve content file paths (images, audio, etc.) + */ +H5P.getPath = function (path, contentId) { + if (path.substr(0, 7) === 'http://' || path.substr(0, 8) === 'https://') { + return path; + } + if (H5P.contentBasePath) { + return H5P.contentBasePath + '/' + path; + } + return path; +}; + +/** + * HTML-escape a string for safe rendering as title/text + */ +H5P.createTitle = function (rawTitle) { + if (!rawTitle) return ''; + var div = document.createElement('div'); + div.textContent = rawTitle; + return div.innerHTML; +}; + +/** + * Generate a random UUID + */ +H5P.createUUID = function () { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0; + var v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +}; + +/** + * Fisher-Yates shuffle + */ +H5P.shuffleArray = function (arr) { + if (!Array.isArray(arr)) return arr; + for (var i = arr.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } + return arr; +}; + +/** + * String trim wrapper + */ +H5P.trim = function (value) { + return (typeof value === 'string') ? value.trim() : value; +}; + +/** + * JSON deep clone + */ +H5P.cloneObject = function (object, recursive) { + var clone = object instanceof Array ? [] : {}; + for (var i in object) { + if (object.hasOwnProperty(i)) { + if (recursive !== undefined && recursive && typeof object[i] === 'object' && object[i] !== null) { + clone[i] = H5P.cloneObject(object[i], recursive); + } else { + clone[i] = object[i]; + } + } + } + return clone; +}; + +/** + * Resolve "H5P.MultiChoice" → H5P.MultiChoice constructor + */ +H5P.classFromName = function (name) { + var parts = name.split('.'); + var current = window; + for (var i = 0; i < parts.length; i++) { + current = current[parts[i]]; + if (!current) return undefined; + } + return current; +}; + +/** + * Attach a library instance to a container + */ +H5P.newRunnable = function (library, contentId, $attachTo, skipResize, extras) { + var nameSplit, versionSplit; + + try { + if (typeof library === 'string') { + // Parse "H5P.MultiChoice 1.16" + var parts = library.split(' '); + nameSplit = parts[0]; + versionSplit = parts[1] ? parts[1].split('.') : [1, 0]; + } else if (library.library) { + var lparts = library.library.split(' '); + nameSplit = lparts[0]; + versionSplit = lparts[1] ? lparts[1].split('.') : [1, 0]; + } else if (library.machineName) { + nameSplit = library.machineName; + versionSplit = [library.majorVersion || 1, library.minorVersion || 0]; + } else { + return undefined; + } + } catch (e) { + return undefined; + } + + var constructor = H5P.classFromName(nameSplit); + if (typeof constructor !== 'function') { + console.warn('H5P: Library not loaded:', nameSplit, '- rendering placeholder'); + // Return a stub instance so parent content types (e.g. Column) don't crash + var stub = new H5P.EventDispatcher(); + stub.libraryInfo = { machineName: nameSplit, majorVersion: parseInt(versionSplit[0]), minorVersion: parseInt(versionSplit[1]) }; + stub.contentId = contentId; + stub.attach = function ($container) { + $container.html('
' + + '' + nameSplit + ' — Library not available for preview.' + + '
'); + }; + if ($attachTo) { + stub.attach(H5P.jQuery($attachTo)); + } + return stub; + } + + var params = (library && library.params) ? library.params : {}; + var subContentId = (library && library.subContentId) ? library.subContentId : undefined; + var metadata = (library && library.metadata) ? library.metadata : {}; + + extras = extras || {}; + extras.metadata = metadata; + extras.subContentId = subContentId; + + var instance; + try { + instance = new constructor(params, contentId, extras); + } catch (e) { + console.warn('H5P: Failed to create instance of', nameSplit, e, '- rendering placeholder'); + var fallback = new H5P.EventDispatcher(); + fallback.libraryInfo = { machineName: nameSplit, majorVersion: parseInt(versionSplit[0]), minorVersion: parseInt(versionSplit[1]) }; + fallback.contentId = contentId; + fallback.attach = function ($container) { + $container.html('
' + + '' + nameSplit + ' — Failed to initialize.' + + '
'); + }; + if ($attachTo) { + fallback.attach(H5P.jQuery($attachTo)); + } + return fallback; + } + + if (instance) { + instance.libraryInfo = { + machineName: nameSplit, + majorVersion: parseInt(versionSplit[0]), + minorVersion: parseInt(versionSplit[1]) + }; + instance.contentId = contentId; + instance.subContentId = subContentId; + + if ($attachTo) { + instance.attach(H5P.jQuery($attachTo)); + } + + H5P.instances.push(instance); + } + + return instance; +}; + +/** + * Translate function — returns the text as-is in preview mode. + * H5P libraries call H5P.t() for i18n strings. + */ +H5P.t = function (key, vars, ns) { + // In preview mode, just return the key or l10n default + return key; +}; + +/** + * Get the user's locale/language + */ +H5P.getLanguage = function () { + return 'en'; +}; + +/** + * Communicate with host (no-op in preview) + */ +H5P.communicator = { + on: function () {}, + send: function () {} +}; + +/** + * Clipboard — stub for copy/paste support + */ +H5P.clipboardify = function () {}; +H5P.getClipboard = function () { return null; }; +H5P.setClipboard = function () {}; + +/** + * Confirmation dialog stub + */ +H5P.ConfirmationDialog = function (options) { + var self = this; + H5P.EventDispatcher.call(self); + self.options = options || {}; + + self.show = function () { return self; }; + self.hide = function () { return self; }; + self.getElement = function () { + return H5P.jQuery('
')[0]; + }; + self.appendTo = function () { return self; }; + self.setOffset = function () { return self; }; +}; +H5P.ConfirmationDialog.prototype = Object.create(H5P.EventDispatcher.prototype); +H5P.ConfirmationDialog.prototype.constructor = H5P.ConfirmationDialog; + +/** + * Content user data — stub for save/load state + */ +H5P.getUserData = function (contentId, dataType, done) { + if (typeof done === 'function') { + done(undefined, null); + } +}; + +H5P.setUserData = function () {}; +H5P.deleteUserData = function () {}; + +/** + * Fullscreen — stub + */ +H5P.fullScreen = function ($element, instance) {}; +H5P.isFullscreen = false; +H5P.fullScreenBrowserPrefix = undefined; +H5P.semiFullScreen = function () {}; +H5P.exitFullScreen = function () {}; + +/** + * Content copyrights — stub + */ +H5P.ContentCopyrights = function () { + this.media = []; + this.content = []; + this.addMedia = function (media) { this.media.push(media); }; + this.addContent = function (content) { this.content.push(content); }; + this.toString = function () { return ''; }; +}; + +H5P.MediaCopyright = function (copyright, labels, order) { + this.copyright = copyright || {}; + this.toString = function () { return ''; }; +}; + +H5P.Thumbnail = function (source, width, height) { + this.source = source; + this.width = width; + this.height = height; + this.toString = function () { return ''; }; +}; + +H5P.getCopyrights = function () { return ''; }; + +/** + * Tooltip stub + */ +H5P.Tooltip = H5P.Tooltip || function (element, options) { + // Simple tooltip — no-op for preview +}; + +/** + * H5P.Transition helper + */ +H5P.Transition = H5P.Transition || { + onTransitionEnd: function ($element, callback, timeout) { + if (typeof callback === 'function') { + setTimeout(callback, timeout || 0); + } + } +}; + +/** + * Resize observer/trigger + */ +H5P.trigger = function (instance, eventName, data) { + if (instance && instance.trigger) { + instance.trigger(eventName, data); + } +}; + +H5P.on = function (instance, eventName, callback) { + if (instance && instance.on) { + instance.on(eventName, callback); + } +}; + +/** + * $body — set during init + */ +H5P.$body = null; +H5P.$window = null; + +/** + * Dialog class — used by some content types + */ +H5P.Dialog = function (name, title, content, $element) { + var self = this; + H5P.EventDispatcher.call(self); + + var $dialog = H5P.jQuery(''); + + self.open = function () { + $dialog.addClass('h5p-open'); + $dialog.find('.h5p-close').on('click', function () { self.close(); }); + if ($element) $element.append($dialog); + return self; + }; + + self.close = function () { + $dialog.removeClass('h5p-open'); + self.trigger('close'); + return self; + }; + + self.getElement = function () { return $dialog; }; +}; +H5P.Dialog.prototype = Object.create(H5P.EventDispatcher.prototype); +H5P.Dialog.prototype.constructor = H5P.Dialog; + +/** + * JoubelScoreBar integration — used by Question types + */ +H5P.JoubelScoreBar = H5P.JoubelScoreBar || function (maxScore, label, helpText, scoreExplanationButtonLabel) { + var self = this; + H5P.EventDispatcher.call(self); + + self.setScore = function (score) {}; + self.setMaxScore = function (maxScore) {}; + self.getElement = function () { return H5P.jQuery('
'); }; + self.appendTo = function ($container) {}; +}; + +/** + * Main init function — called to bootstrap H5P content + */ +H5P.init = function (container, integration) { + if (!container || !integration) { + console.error('H5P.init: container and integration required'); + return; + } + + var $ = H5P.jQuery; + if (!$) { + console.error('H5P.init: jQuery is required'); + return; + } + + H5P.$body = $('body'); + H5P.$window = $(window); + + // Set up content path resolution + H5P.contentBasePath = integration.contentPath || ''; + + var contentData; + try { + contentData = typeof integration.contentData === 'string' + ? JSON.parse(integration.contentData) + : integration.contentData; + } catch (e) { + console.error('H5P.init: Failed to parse content data', e); + return; + } + + if (!contentData) { + console.error('H5P.init: No content data'); + return; + } + + // Build the library string "H5P.MultiChoice 1.16" + var libraryString = integration.mainLibrary; + if (!libraryString) { + console.error('H5P.init: mainLibrary not specified'); + return; + } + + var contentId = integration.contentId || 'preview-' + H5P.createUUID(); + + // Store content data for potential sub-content access + H5P.contentDatas[contentId] = contentData; + + // Create the wrapper + var $container = $(container); + $container.addClass('h5p-content h5p-initialized'); + $container.attr('data-content-id', contentId); + + // Create the runnable + var library = { + library: libraryString, + params: contentData, + metadata: integration.metadata || { title: integration.title || 'H5P Preview' } + }; + + var instance = H5P.newRunnable(library, contentId, $container, false, { + metadata: library.metadata + }); + + if (!instance) { + $container.html('

Failed to initialize H5P content. The main library "' + + libraryString + '" could not be loaded.

'); + return; + } + + // Trigger initial resize + if (instance.$ && instance.$.trigger) { + instance.$.trigger('resize'); + } + if (instance.trigger) { + instance.trigger('resize'); + } + + // Listen for window resize + $(window).on('resize', function () { + if (instance.trigger) { + instance.trigger('resize'); + } + }); + + return instance; +}; diff --git a/server.js b/server.js index 933c4da..5636fb7 100644 --- a/server.js +++ b/server.js @@ -164,6 +164,9 @@ app.post('/Shibboleth.sso/SAML2/POST', (req, res) => { app.handle(req, res); }); +// Static serving for extracted H5P preview files (before API routes to avoid rate limiting) +app.use('/h5p-preview-files', express.static(path.join(__dirname, 'routes', 'create', 'uploads', 'h5p-preview'))); + // Mount the API router FIRST (before static files) app.use('/api/create', createRoutes); diff --git a/src/App.tsx b/src/App.tsx index 277e351..cf07da1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import QuizView from './components/QuizView'; import UserAccount from './components/UserAccount'; import Login from './components/Login'; import NotFound from "./pages/NotFound"; +import H5PPreview from "./pages/H5PPreview"; import { useState, useEffect } from 'react'; import { API_URL } from './config/api'; @@ -66,6 +67,9 @@ const App = () => { : } /> : } /> + {/* H5P Preview — no auth required (dev tool) */} + } /> + {/* SAML callback route */} } /> diff --git a/src/pages/H5PPreview.tsx b/src/pages/H5PPreview.tsx new file mode 100644 index 0000000..9655f86 --- /dev/null +++ b/src/pages/H5PPreview.tsx @@ -0,0 +1,208 @@ +import { useState, useRef } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; +import { Button } from '../components/ui/button'; +import { Upload, FileUp, Loader2, AlertCircle, ExternalLink } from 'lucide-react'; +import { API_URL } from '../config/api'; + +interface UploadResult { + id: string; + title: string; + mainLibrary: string; +} + +const H5PPreview = () => { + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const fileInputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const selected = e.target.files?.[0]; + if (selected) { + setFile(selected); + setError(null); + setResult(null); + } + }; + + const handleUpload = async () => { + if (!file) return; + + setUploading(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('h5pFile', file); + + const response = await fetch(`${API_URL}/api/create/h5p-preview/upload`, { + method: 'POST', + body: formData, + }); + + const data = await response.json(); + + if (!response.ok || !data.success) { + throw new Error(data.error?.message || 'Upload failed'); + } + + setResult(data.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setUploading(false); + } + }; + + const handleReset = () => { + setFile(null); + setResult(null); + setError(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + // Use relative path so the iframe goes through vite proxy (same origin — avoids CSP frame-ancestors block) + const renderUrl = result + ? `/api/create/h5p-preview/${result.id}/render` + : null; + + return ( +
+
+

+ H5P Preview +

+

+ Upload an .h5p file to preview it with real H5P library rendering. +

+ + {/* Upload Card */} + + + + + Upload H5P File + + + Select a .h5p file exported from this app or any H5P editor. + + + +
+ + + {result && ( + + )} +
+ + {error && ( +
+ + {error} +
+ )} + + {result && ( +
+ {result.title} — {result.mainLibrary} + {renderUrl && ( + + Open in new tab + + )} +
+ )} +
+
+ + {/* Preview iframe */} + {renderUrl && ( + + + Preview: {result?.title} + + +