diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000000..cd1af528ca --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,158 @@ +# 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" + + # ───────────────────────────────────────────────────────────────── + # Cross-repo dispatch: tell ChandanaSRooman/LMS that the MFE source + # has new commits, so its `build-mfe-bundle.yml` workflow rebuilds + # the unified MFE bundle (all 11 MFEs + the Rooman fork at this sha) + # and deploys it to the dev EC2. + # + # Why dispatch and not just trigger via the image push? + # Because the LMS-side build needs to clone the MFE source at a + # specific sha, not pull an image. It does its OWN Tutor build + # combining 11 MFEs into one container. So we pass the sha + ref + # along for reproducibility. + # + # Requires a PAT in the secret `LMS_MONOREPO_DISPATCH_PAT` with: + # - fine-grained, scoped only to ChandanaSRooman/LMS + # - permission: Repository contents = read, Actions = write + # Without the secret, this step prints a warning and exits 0 — + # the MFE image push still succeeded, the LMS bundle just won't + # auto-rebuild until the secret is set OR someone manually triggers + # workflow_dispatch on build-mfe-bundle.yml. + # ───────────────────────────────────────────────────────────────── + - name: Dispatch bundle rebuild to ChandanaSRooman/LMS + # Only fire on real source pushes — skip workflow_dispatch + # re-runs so we don't infinite-loop. + if: github.event_name == 'push' + env: + DISPATCH_PAT: ${{ secrets.LMS_MONOREPO_DISPATCH_PAT }} + DISPATCH_TARGET: ChandanaSRooman/LMS + run: | + set -euo pipefail + if [[ -z "${DISPATCH_PAT:-}" ]]; then + echo "::warning::LMS_MONOREPO_DISPATCH_PAT secret not set on this repo." + echo "::warning::MFE image was built+pushed OK, but the LMS-side bundle won't auto-rebuild." + echo "::warning::Trigger build-mfe-bundle.yml manually, or set the secret to enable auto-dispatch." + exit 0 + fi + HTTP_CODE=$(curl -sS -L -o /tmp/dispatch.body -w '%{http_code}' \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${DISPATCH_PAT}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${DISPATCH_TARGET}/dispatches" \ + -d "{\"event_type\":\"mfe-source-updated\",\"client_payload\":{\"sha\":\"${GITHUB_SHA}\",\"ref\":\"${GITHUB_REF}\",\"image\":\"${{ steps.tags.outputs.image }}:${{ steps.tags.outputs.branch_tag }}\"}}") + if [[ "$HTTP_CODE" != "204" ]]; then + echo "::error::Dispatch failed with HTTP $HTTP_CODE" + cat /tmp/dispatch.body + exit 1 + fi + echo " -> dispatched mfe-source-updated to ${DISPATCH_TARGET}" + echo " sha=${GITHUB_SHA}" + echo " downstream workflow: https://github.com/${DISPATCH_TARGET}/actions/workflows/build-mfe-bundle.yml" + { + echo "" + echo "### Downstream bundle rebuild dispatched" + echo "" + echo "[Watch the LMS-side build-mfe-bundle run](https://github.com/${DISPATCH_TARGET}/actions/workflows/build-mfe-bundle.yml)" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..76cbb02f6c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# 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) +# +# PUBLIC_PATH=/learning/ is required so webpack (via fedx-scripts) emits +# script tags as src="/learning/runtime.xxx.js" matching the path prefix +# that Tutor's MFE Caddyfile serves assets under. fedx-scripts reads +# PUBLIC_PATH (not PUBLIC_URL) — see the Tutor MFE Dockerfile template. + +# ─── Stage 1: build the React bundle ──────────────────────────────────────── +FROM docker.io/node:20-bookworm-slim AS builder + +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 + +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +COPY . . + +RUN cat > env.config.jsx <<'EOF' +const config = { + ...process.env, + pluginSlots: {}, +}; +export default config; +EOF + +ARG APP_NAME=learning +ENV APP_NAME=${APP_NAME} +ENV APP_ID=learning +ENV NODE_ENV=production +ENV PUBLIC_PATH=/learning/ +ENV MFE_CONFIG_API_URL=/api/mfe_config/v1 +RUN npm run build + +# ─── Stage 2: serve the static bundle ─────────────────────────────────────── +FROM docker.io/caddy:2-alpine AS server + +COPY --from=builder /app/dist /usr/share/caddy + +RUN printf '%s\n' \ + ':8080 {' \ + ' root * /usr/share/caddy' \ + ' encode gzip' \ + ' try_files {path} {path}/index.html /index.html' \ + ' file_server' \ + '}' \ + > /etc/caddy/Caddyfile + +EXPOSE 8080 + +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] 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} +
+ )} +
+ +
+