From 92c60c1cf48f15e4dc963c166218c5d917e71638 Mon Sep 17 00:00:00 2001 From: PunithRooman Date: Wed, 20 May 2026 17:25:56 +0530 Subject: [PATCH 1/8] ci: build + push MFE Docker image to GHCR Drops in the workflow template from upstream Rooman LMS repo (edx/ops/mfe/workflow.example.yml). On every push to rooman/main: - Build the upstream Dockerfile (which the MFE already ships) - Push to ghcr.io/punithrooman/rooman-frontend-app-learning with both :latest and a :rooman-main- tag (reproducible for prod deploys) - GHA cache cuts subsequent builds from ~6 min to ~90s After this lands, the LMS box can pull the resulting image by setting MFE_LEARNING_DOCKER_IMAGE in ~/.local/share/tutor/config.yml. Branch base: open-release/sumac.master (the latest stable Open edX MFE release as of May 2026). When Verbena MFE release branches drop, rebase rooman/main onto open-release/verbena.master. --- .github/workflows/build-and-push.yml | 101 +++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/workflows/build-and-push.yml diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000000..4341a68df3 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,101 @@ +# Drop-in GitHub Actions workflow for the Learning MFE fork repo. +# +# Copy into your fork repo as `.github/workflows/build-and-push.yml`. +# Builds the MFE Docker image on every commit to the default branch +# (we suggest `rooman/main` per the parent README) and pushes it to +# GitHub Container Registry as +# ghcr.io//:- +# ghcr.io//:latest +# +# Permissions: this workflow requires `packages: write` so it can push to +# GHCR using the repo's built-in GITHUB_TOKEN — no extra secrets needed. +# +# After the first successful run, the image is pull-able from any host +# (Tutor on the LMS box) with: +# docker pull ghcr.io//:latest +# +# For private repos: GHCR images default to private too. Either make the +# package public (Settings → Packages on GitHub) or add a docker login +# step on the LMS box pointing at a personal access token with read:packages. + +name: Build and push Learning MFE image + +on: + push: + branches: [rooman/main] + workflow_dispatch: # lets you re-run a build from the Actions tab + +# Cancel in-flight builds when a newer push lands on the same branch. +# MFE builds take 4-6 min; nobody wants the slow one to win. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set image tags + id: tags + run: | + # Lowercase owner — GHCR rejects mixed-case paths. + OWNER="${GITHUB_REPOSITORY_OWNER,,}" + REPO_LOWER="${GITHUB_REPOSITORY,,}" # owner/repo lowercased + SHORT_SHA="${GITHUB_SHA::7}" + BRANCH_TAG="${GITHUB_REF_NAME//\//-}-${SHORT_SHA}" + echo "image=ghcr.io/${REPO_LOWER}" >> "$GITHUB_OUTPUT" + echo "branch_tag=${BRANCH_TAG}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + # Upstream openedx/frontend-app-learning ships a Dockerfile at + # the repo root — we use it unchanged. + file: ./Dockerfile + # `latest` makes the LMS box's `tutor images pull mfe` always + # grab the newest build. The sha-suffix tag is what you set in + # config.yml.example for reproducible prod deploys. + tags: | + ${{ steps.tags.outputs.image }}:latest + ${{ steps.tags.outputs.image }}:${{ steps.tags.outputs.branch_tag }} + push: true + # Reuse layers across builds so the typical "edit one React + # file" change rebuilds in ~90s instead of 6 min. + cache-from: type=gha + cache-to: type=gha,mode=max + # The MFE Dockerfile takes one build-arg: the MFE name. Tutor + # injects all runtime config (LMS_BASE_URL etc.) at container + # start via env vars — nothing to bake in here. + build-args: | + APP_NAME=learning + + - name: Summary + run: | + echo "### Image pushed" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.tags.outputs.image }}:latest" >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.tags.outputs.image }}:${{ steps.tags.outputs.branch_tag }}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Deploy with:" >> "$GITHUB_STEP_SUMMARY" + echo '```bash' >> "$GITHUB_STEP_SUMMARY" + echo "tutor config save --set MFE_LEARNING_DOCKER_IMAGE=${{ steps.tags.outputs.image }}:latest" >> "$GITHUB_STEP_SUMMARY" + echo "tutor images pull mfe && tutor local restart mfe" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" From 6aac5ffdab26a062c72ed3f4506520b5cb61482c Mon Sep 17 00:00:00 2001 From: PunithRooman Date: Wed, 20 May 2026 17:28:55 +0530 Subject: [PATCH 2/8] build: add multi-stage Dockerfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream `openedx/frontend-app-learning` doesn't ship a Dockerfile — Tutor's mfe plugin builds the image itself using its own template. For our fork we need a Dockerfile so the GHA workflow can build a production image and push it to GHCR. Two stages: 1. node:20-bookworm-slim builder - apt-get the toolchain fedx-scripts needs (git, python3, build-essential) - npm ci with patches/ pre-copied so patch-package runs in postinstall - generate env.config.jsx from the upstream example if missing (Tutor injects the real runtime config at container start, not here) - npm run build → dist/ 2. caddy:2-alpine server - COPY --from=builder /app/dist → /usr/share/caddy - Caddyfile with try_files fallback for SPA routes - listen on :8080 (matches Tutor's expected MFE container port) Tutor's MFE plugin injects runtime config (LMS_BASE_URL etc.) via env vars + a generated /static/env.config.js at container start; this image deliberately doesn't bake anything host-specific in so the same image works in dev + prod. Build locally with: `docker build -t test .` (takes ~6 min cold, ~90s cached). --- Dockerfile | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..264f3836ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,82 @@ +# Production Docker image for the Rooman Learning MFE. +# +# Multi-stage build: +# 1. `builder` — Node 20 + npm to compile the React app via fedx-scripts +# 2. `server` — Caddy serving the static `dist/` output with SPA fallback +# (so client-side routes like /learning/course/.../sequence +# work on direct page loads, not just navigation) +# +# Tutor injects runtime config (LMS_BASE_URL, STUDIO_BASE_URL, etc.) at +# *container start* via environment variables that the MFE reads at boot +# through `process.env.*`. The MFE doesn't need rebuild-time env vars +# beyond APP_NAME — fedx-scripts handles the rest. +# +# Build context is the repo root. Build with: +# docker build -t ghcr.io/punithrooman/rooman-frontend-app-learning:latest . + +# ─── Stage 1: build the React bundle ──────────────────────────────────────── +FROM docker.io/node:20-bookworm-slim AS builder + +# fedx-scripts shells out to webpack/babel — needs the OS toolchain for +# native dep compile (e.g. node-sass historically; sharp/imagemin still). +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + python3 \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install deps first so the layer caches when only source changes. +# Copy lockfiles BEFORE source so a code edit doesn't bust the npm cache. +COPY package.json package-lock.json ./ +COPY patches ./patches +# `--ignore-scripts=false` so the MFE's postinstall (which patches a +# couple of upstream node_modules via `patch-package`) actually runs. +RUN npm ci --no-audit --no-fund + +# Now the source. +COPY . . + +# The MFE build reads a JSX-format runtime config at compile time too — +# upstream ships an `example.env.config.jsx` but no real one. Tutor's +# build normally generates one from `MFE_CONFIG` / `MFE_CONFIG_OVERRIDES`; +# since we're outside Tutor here, fall back to the example so webpack +# has something to import. Tutor injects the REAL config at container +# runtime via env vars + a generated /static/env.config.js. +RUN test -f env.config.jsx || cp example.env.config.jsx env.config.jsx + +# fedx-scripts is the OpenEdx-blessed webpack wrapper. APP_NAME tells it +# which MFE we're building (the same Dockerfile pattern works for every +# frontend-app-* repo). +ARG APP_NAME=learning +ENV APP_NAME=${APP_NAME} +ENV NODE_ENV=production +RUN npm run build + +# ─── Stage 2: serve the static bundle ─────────────────────────────────────── +FROM docker.io/caddy:2-alpine AS server + +# Copy the built bundle from the builder stage. fedx-scripts emits to `dist/`. +COPY --from=builder /app/dist /usr/share/caddy + +# A SPA-aware Caddyfile so deep links work. `try_files` falls back to +# index.html for any path that isn't an actual file, which is what +# React Router needs to handle /learning/course/... routes. +RUN printf '%s\n' \ + ':8080 {' \ + ' root * /usr/share/caddy' \ + ' encode gzip' \ + ' # Tutor MFE plugin generates env.config.js + writes it into this' \ + ' # directory at container start. The runtime config (LMS_BASE_URL etc.)' \ + ' # comes through here, not baked into the static bundle.' \ + ' try_files {path} {path}/index.html /index.html' \ + ' file_server' \ + '}' \ + > /etc/caddy/Caddyfile + +EXPOSE 8080 + +# Caddy 2 default entrypoint already runs the Caddyfile; explicit for clarity. +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] From 931ceb6c68f6bcaaf13aebd411eb1c59bb7e314b Mon Sep 17 00:00:00 2001 From: PunithRooman Date: Wed, 20 May 2026 17:33:55 +0530 Subject: [PATCH 3/8] fix(build): generate env.config.jsx inline (it's gitignored upstream) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env.config.jsx is gitignored in the upstream MFE — it's meant to be per-deployment local config, not committed. So my last attempt (a checked-in env.config.jsx) couldn't be staged. Better: generate the minimal file inline in the Dockerfile via a heredoc. The file ends up the same — exposes process.env + an empty pluginSlots map — but the build remains self-contained and doesn't fight upstream's .gitignore. When Rooman adds custom plugin slots (AI tutor sidebar etc.), replace the heredoc with a checked-in file at a non-ignored path: config/env.config.jsx # added to fork, not gitignored COPY config/env.config.jsx ./ # before npm run build Until then the heredoc is the simplest path. --- Dockerfile | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 264f3836ac..7ae74cbd07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,13 +39,29 @@ RUN npm ci --no-audit --no-fund # Now the source. COPY . . -# The MFE build reads a JSX-format runtime config at compile time too — -# upstream ships an `example.env.config.jsx` but no real one. Tutor's -# build normally generates one from `MFE_CONFIG` / `MFE_CONFIG_OVERRIDES`; -# since we're outside Tutor here, fall back to the example so webpack -# has something to import. Tutor injects the REAL config at container -# runtime via env vars + a generated /static/env.config.js. -RUN test -f env.config.jsx || cp example.env.config.jsx env.config.jsx +# The MFE build reads `env.config.jsx` at webpack time. Upstream's +# .gitignore excludes this file (it's meant to be per-deployment local +# config) — generate a minimal one inline here so the build is +# self-contained. Tutor injects the *runtime* config (LMS_BASE_URL etc.) +# via process.env at container start, completely separate from this +# build-time file. +# +# Upstream's `example.env.config.jsx` is NOT a safe fallback — it +# imports an optional `@edx/unit-translation-selector-plugin` package +# that isn't in package.json and breaks the webpack build with a module +# resolution error. +# +# When Rooman adds custom plugin slots (AI tutor sidebar, etc.), +# replace this inline heredoc with a real env.config.jsx checked into +# the fork at a non-gitignored path (e.g. config/env.config.jsx, then +# `COPY config/env.config.jsx ./env.config.jsx` before npm run build). +RUN cat > env.config.jsx <<'EOF' +const config = { + ...process.env, + pluginSlots: {}, +}; +export default config; +EOF # fedx-scripts is the OpenEdx-blessed webpack wrapper. APP_NAME tells it # which MFE we're building (the same Dockerfile pattern works for every From 24c331bd72035377465937be1abf9b8252591560 Mon Sep 17 00:00:00 2001 From: PunithRooman Date: Wed, 20 May 2026 17:52:03 +0530 Subject: [PATCH 4/8] feat: Rooman AI Tutor sidebar in every course unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new sidebar (alongside Discussions + Notifications) that gives the learner an in-context chat with the Rooman tutor backend. Wired to POST /api/practice/tutor/chat on the lab platform. What learners see ───────────────── - New chat-bubble icon in the courseware sidebar trigger column, topmost in the order. - Click → 30rem-wide panel slides in from the right with the title "Rooman AI Tutor". - Empty state: "Ask anything about this lesson…" - Type a question, press Enter (Shift+Enter for newline), get a reply from the LLM via the lab platform. - Multi-turn: history persists in component state for the session; refresh wipes (deliberate for v0.1 — no per-learner server-side history yet). - On error: the learner's typed message is restored so they can edit and retry without losing what they wrote. Files added (all additive — no upstream-file edits except the sidebars registry, which is intentionally a small, stable file that rarely moves): src/courseware/course/sidebar/sidebars/rooman-tutor/ ├── index.js re-exports ├── messages.ts i18n strings ├── RoomanTutorTrigger.jsx sidebar trigger button └── RoomanTutorSidebar.jsx the chat panel src/courseware/course/sidebar/sidebars/index.js Added roomanTutor entry to SIDEBARS map + SIDEBAR_ORDER. Configuration ───────────── Lab platform host comes from `process.env.LAB_PLATFORM_BASE_URL` with a default of `https://dev-labs.13-232-120-92.sslip.io`. Override per deployment via Tutor's MFE_CONFIG_OVERRIDES in config.yml: MFE_CONFIG_OVERRIDES: learning: LAB_PLATFORM_BASE_URL: https://dev-labs.13-232-120-92.sslip.io CORS: this sidebar issues a cross-origin POST from apps.dev-lms. to dev-labs.. The lab platform's CORS middleware allows `*`, but browsers reject the `*`-origin + `allow_credentials=true` combination. We send `credentials: 'omit'` — the chat endpoint is unauth so we don't need them. Rebase discipline ───────────────── Everything lives in a new sidebar directory (additive), with one small touch to `sidebars/index.js` (the registry — stable file that rarely moves upstream). Rebases of this work onto the next Open edX release should be ~5 minutes. --- .../course/sidebar/sidebars/index.js | 11 + .../rooman-tutor/RoomanTutorSidebar.jsx | 215 ++++++++++++++++++ .../rooman-tutor/RoomanTutorTrigger.jsx | 38 ++++ .../sidebar/sidebars/rooman-tutor/index.js | 2 + .../sidebar/sidebars/rooman-tutor/messages.ts | 41 ++++ 5 files changed, 307 insertions(+) create mode 100644 src/courseware/course/sidebar/sidebars/rooman-tutor/RoomanTutorSidebar.jsx create mode 100644 src/courseware/course/sidebar/sidebars/rooman-tutor/RoomanTutorTrigger.jsx create mode 100644 src/courseware/course/sidebar/sidebars/rooman-tutor/index.js create mode 100644 src/courseware/course/sidebar/sidebars/rooman-tutor/messages.ts diff --git a/src/courseware/course/sidebar/sidebars/index.js b/src/courseware/course/sidebar/sidebars/index.js index fe89ecd66c..6154285bba 100644 --- a/src/courseware/course/sidebar/sidebars/index.js +++ b/src/courseware/course/sidebar/sidebars/index.js @@ -1,5 +1,6 @@ import * as notifications from './notifications'; import * as discussions from './discussions'; +import * as roomanTutor from './rooman-tutor'; export const SIDEBARS = { [notifications.ID]: { @@ -12,9 +13,19 @@ export const SIDEBARS = { Sidebar: discussions.Sidebar, Trigger: discussions.Trigger, }, + // Rooman addition — in-course AI tutor. New custom sidebar, not from + // upstream Open edX. When rebasing this file across releases, keep + // this entry intact — it's the entire Layer-3 sidebar wiring. + [roomanTutor.ID]: { + ID: roomanTutor.ID, + Sidebar: roomanTutor.Sidebar, + Trigger: roomanTutor.Trigger, + }, }; export const SIDEBAR_ORDER = [ + // Rooman tutor sits at the top so learners see the icon first. + roomanTutor.ID, discussions.ID, notifications.ID, ]; diff --git a/src/courseware/course/sidebar/sidebars/rooman-tutor/RoomanTutorSidebar.jsx b/src/courseware/course/sidebar/sidebars/rooman-tutor/RoomanTutorSidebar.jsx new file mode 100644 index 0000000000..c3b2df5371 --- /dev/null +++ b/src/courseware/course/sidebar/sidebars/rooman-tutor/RoomanTutorSidebar.jsx @@ -0,0 +1,215 @@ +/** + * Rooman AI Tutor — sidebar chat panel. + * + * Fetches answers from the Rooman Lab Platform's POST /api/practice/tutor/chat. + * The MFE runs at `apps.dev-lms.` and the lab platform runs at + * `dev-labs.` — cross-origin. The lab platform's CORS middleware + * allows `*` so the fetch works without credentials; we pass + * `credentials: 'omit'` so browsers don't reject the `*`-origin + creds + * combination. + * + * Conversation state is in-memory only (component-local useState). Refresh + * = lost. That's deliberate for v0.1 — persisting per-learner history + * needs a server-side log we haven't designed yet. + * + * Configuration: + * process.env.LAB_PLATFORM_BASE_URL controls the lab platform host. Set + * via Tutor's MFE_CONFIG_OVERRIDES in `~/.local/share/tutor/config.yml`: + * + * MFE_CONFIG_OVERRIDES: + * learning: + * LAB_PLATFORM_BASE_URL: https://dev-labs.13-232-120-92.sslip.io + * + * The default fallback below works for the dev box. Override for prod. + */ +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useModel } from '@src/generic/model-store'; +import PropTypes from 'prop-types'; + +import SidebarBase from '../../common/SidebarBase'; +import SidebarContext from '../../SidebarContext'; +import { ID } from './RoomanTutorTrigger'; +import messages from './messages'; + +const LAB_PLATFORM_BASE_URL = ( + process.env.LAB_PLATFORM_BASE_URL + || 'https://dev-labs.13-232-120-92.sslip.io' +); +const CHAT_URL = `${LAB_PLATFORM_BASE_URL.replace(/\/$/, '')}/api/practice/tutor/chat`; + +/** + * Trim the chat history we send to the server so we don't blow past the + * LLM's token budget. The endpoint caps history at 10 turns; we send the + * last 6 user-or-assistant turns (3 exchanges) which is the sweet spot + * between "remembers the conversation" and "doesn't replay 50 messages". + */ +const HISTORY_WINDOW = 6; + +const RoomanTutorSidebar = ({ intl }) => { + const { courseId, unitId } = useContext(SidebarContext); + // Course + unit titles flow into the prompt so the LLM has context. + // useModel returns undefined when the data isn't loaded yet; we degrade + // gracefully — the endpoint accepts missing titles. + const courseHomeMeta = useModel('courseHomeMeta', courseId); + const unit = useModel('units', unitId); + const courseTitle = courseHomeMeta?.title || ''; + const unitTitle = unit?.title || ''; + + const [history, setHistory] = useState([]); // [{role, content}] + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + + // Auto-scroll to bottom on every new message. + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [history, sending]); + + const send = useCallback(async () => { + const question = draft.trim(); + if (!question || sending) return; + setSending(true); + setError(null); + + // Optimistic UI: render the learner's turn immediately while we wait. + const optimisticHistory = [...history, { role: 'user', content: question }]; + setHistory(optimisticHistory); + setDraft(''); + + try { + const res = await fetch(CHAT_URL, { + method: 'POST', + credentials: 'omit', // see file-top comment re CORS + credentials + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + question, + course_id: courseId || null, + unit_id: unitId || null, + course_title: courseTitle, + unit_title: unitTitle, + // Send the last few turns so the LLM remembers the thread. + history: history.slice(-HISTORY_WINDOW), + }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`); + } + const data = await res.json(); + setHistory([...optimisticHistory, { role: 'assistant', content: data.text || '' }]); + } catch (e) { + setError(e.message || 'Unknown error'); + // Roll back the optimistic user message so they can edit + retry + // without losing what they typed. + setHistory(history); + setDraft(question); + } finally { + setSending(false); + } + }, [draft, sending, history, courseId, unitId, courseTitle, unitTitle]); + + // Enter = send, Shift+Enter = newline. + const onKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + return ( + +
+
+ {history.length === 0 && !sending ? ( +
+ {intl.formatMessage(messages.placeholderEmpty)} +
+ ) : ( + history.map((turn, i) => ( + // eslint-disable-next-line react/no-array-index-key +
+
+ {turn.content} +
+
+ )) + )} + {sending && ( +
+
+ … +
+
+ )} + {error && ( +
+ {intl.formatMessage(messages.errorPrefix)} +
+ {error} +
+ )} +
+ +
+