feat: productionize learning engine and classroom

This commit is contained in:
inman
2026-08-16 21:44:52 +08:00
parent 2d04197f3f
commit ba35adfbfa
124 changed files with 9071 additions and 796 deletions

View File

@@ -5,6 +5,7 @@ import { AccessCodeModal } from '@/components/access-code-modal';
import { useSettingsStore } from '@/lib/store/settings';
export function AccessCodeGuard({ children }: { children: ReactNode }) {
const isOpsDeployment = process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE === 'ops';
const [status, setStatus] = useState<{
enabled: boolean;
authenticated: boolean;
@@ -12,6 +13,10 @@ export function AccessCodeGuard({ children }: { children: ReactNode }) {
}>({ enabled: false, authenticated: false, loading: true });
useEffect(() => {
if (isOpsDeployment || new URLSearchParams(window.location.search).get('embedded') === '1') {
setStatus({ enabled: false, authenticated: true, loading: false });
return;
}
let cancelled = false;
fetch('/api/access-code/status')
.then((res) => res.json())
@@ -33,7 +38,7 @@ export function AccessCodeGuard({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
};
}, []);
}, [isOpsDeployment]);
const needsAuth = !status.loading && status.enabled && !status.authenticated;

View File

@@ -28,6 +28,7 @@ import { USER_AVATAR } from '@/lib/types/roundtable';
import { StreamBuffer } from '@/lib/buffer/stream-buffer';
import type { AgentStartItem, ActionItem } from '@/lib/buffer/stream-buffer';
import { runAgentLoop, type AgentLoopStoreState } from '@/lib/chat/agent-loop';
import { fetchMakeloreLearningAgent } from '@/lib/makelore-runtime/browser-bridge';
import { ActionEngine } from '@/lib/action/engine';
import {
buildQuizResultsForStoreState,
@@ -1142,6 +1143,33 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
requestTemplate.config.agentConfigs = generatedConfigs;
}
if (window.__MAKELORE_OFFLINE_PLAYER__) {
const streamConsumer = createStatelessStreamConsumer(sessionId, controller, sessionType);
const storeState = await buildFreshAgentLoopStoreState();
const response = await fetchMakeloreLearningAgent({
messages: requestTemplate.messages,
storeState,
}, controller.signal);
if (!response.ok) throw new Error(await response.text());
const reader = response.body?.getReader();
if (!reader) throw new Error('助教没有返回内容');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() || '';
for (const frame of frames) {
const data = frame.split('\n').find((line) => line.startsWith('data: '));
if (data) streamConsumer.onEvent(JSON.parse(data.slice(6)) as StatelessEvent);
}
}
await streamConsumer.onIterationEnd();
return;
}
if (isPiChatEnabled()) {
// Pi bypasses runAgentLoop's per-iteration getStoreState, so its single
// request needs the snapshot built here — /api/chat/pi rejects bodies

View File

@@ -54,6 +54,7 @@ import {
} from '@/components/ui/alert-dialog';
import { AlertTriangle } from 'lucide-react';
import { VisuallyHidden } from 'radix-ui';
import { emitMakelorePlaybackProgress } from '@/lib/makelore-runtime/progress-events';
/**
* Imperative handle exposed via `ref` so the parent (`Stage`) can tear
@@ -221,10 +222,21 @@ export const PlaybackChromeRoot = forwardRef<PlaybackChromeRootHandle, PlaybackC
// Guard to prevent double flash when manual stop triggers onDiscussionEnd
const manualStopRef = useRef(false);
const updateCurrentPlaybackActionIndex = useCallback((actionIndex: number | null) => {
const updateCurrentPlaybackActionIndex = useCallback((
actionIndex: number | null,
completed = false,
) => {
currentPlaybackActionIndexRef.current = actionIndex;
setCurrentPlaybackActionIndex(actionIndex);
}, []);
if (currentScene) {
emitMakelorePlaybackProgress({
sceneId: currentScene.id,
sceneOrder: currentScene.order,
actionIndex,
completed,
});
}
}, [currentScene]);
const persistCursorSafely = useCallback(
({ stageId, cursor }: { stageId: string; cursor: PlaybackCursor }) => {
@@ -598,8 +610,33 @@ export const PlaybackChromeRoot = forwardRef<PlaybackChromeRootHandle, PlaybackC
)
: { actionIndex: 0, position: null };
let savedResumeActionIndex = sessionResumeCursor.actionIndex;
const offlineProgress = typeof window !== 'undefined'
? window.__MAKELORE_INITIAL_PROGRESS__
: undefined;
let restoredOfflineProgress = false;
if (
currentScene
&& offlineProgress?.sceneOrder === currentScene.order
&& Number.isInteger(offlineProgress.actionIndex)
&& offlineProgress.actionIndex! >= 0
&& currentScene.actions?.[offlineProgress.actionIndex!]
&& canJumpWithinReconstructablePrefix(
currentScene.actions,
0,
offlineProgress.actionIndex!,
)
) {
savedResumeActionIndex = offlineProgress.actionIndex!;
restoredOfflineProgress = true;
delete window.__MAKELORE_INITIAL_PROGRESS__;
}
const playbackStageId = stage?.id ?? currentScene?.stageId;
if (currentScene && playbackStageId && !sessionResumeCursor.position) {
if (
currentScene
&& playbackStageId
&& !sessionResumeCursor.position
&& !restoredOfflineProgress
) {
try {
const cursor = await loadCursor(playbackStageId);
if (
@@ -766,6 +803,7 @@ export const PlaybackChromeRoot = forwardRef<PlaybackChromeRootHandle, PlaybackC
// If all actions are exhausted (discussion was the last action), mark
// playback as completed so the bubble shows reset instead of play.
if (engineRef.current?.isExhausted()) {
updateCurrentPlaybackActionIndex(currentScene.actions?.length ?? 0, true);
setPlaybackCompleted(true);
}
},
@@ -782,7 +820,7 @@ export const PlaybackChromeRoot = forwardRef<PlaybackChromeRootHandle, PlaybackC
// lectureSpeech intentionally NOT cleared — last sentence stays visible
// until scene transition (auto-play) or user restarts. Scene change
// effect handles the reset.
updateCurrentPlaybackActionIndex(currentScene.actions?.length ?? 0);
updateCurrentPlaybackActionIndex(currentScene.actions?.length ?? 0, true);
clearSceneResumePosition(currentScene.id);
setPlaybackCompleted(true);

View File

@@ -16,6 +16,7 @@ import { CHROME_DURATION_MS, CHROME_EASE, CHROME_EASE_CSS } from '@/lib/edit/tra
import type { Scene } from '@/lib/types/stage';
import { ThumbItem } from './ThumbItem';
import { InsertionZone } from './InsertionZone';
import { opsApiPath } from '@/lib/ops/api-fetch';
const RAIL_COLLAPSED_PX = 56;
const RAIL_MIN_PX = 180;
@@ -398,7 +399,7 @@ export function SlideNavRail() {
title={t('generation.backToHome')}
className="flex items-center gap-2 cursor-pointer rounded-lg px-1.5 -mx-1.5 py-1 -my-1 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 active:scale-[0.97] transition-all duration-150"
>
<img src="/mailuo-logo.png" alt="麦洛学习" className="h-6" />
<img src={opsApiPath('/mailuo-logo.png')} alt="麦洛学习" className="h-6" />
</button>
)}
<div className={cn('flex items-center gap-1', collapsed && 'flex-col')}>

View File

@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
import { MediaPopover, type MediaCapabilityOverride } from '@/components/generation/media-popover';
import type { SettingsSection } from '@/lib/types/settings';
import { cn } from '@/lib/utils';
import { opsApiFetch } from '@/lib/ops/api-fetch';
const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
@@ -64,16 +65,13 @@ export function LargeCourseWorkbench({
const [webSearch, setWebSearch] = useState(false);
const [enableImageGeneration, setEnableImageGeneration] = useState(false);
const [enableVideoGeneration, setEnableVideoGeneration] = useState(false);
const [enableTTS, setEnableTTS] = useState(true);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [items, setItems] = useState<CourseListItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
// Large courses always keep narration enabled because publishing requires
// persisted speech audio for every module.
const enableTTS = true;
useEffect(() => {
try {
if (localStorage.getItem(WEB_SEARCH_STORAGE_KEY) === 'true') setWebSearch(true);
@@ -95,7 +93,7 @@ export function LargeCourseWorkbench({
setLoading(true);
setLoadError(null);
try {
const response = await fetch('/api/courses');
const response = await opsApiFetch('/api/courses');
const body = (await response.json()) as { items?: CourseListItem[] };
if (!response.ok || !Array.isArray(body.items)) {
throw new Error(`HTTP ${response.status}`);
@@ -119,7 +117,7 @@ export function LargeCourseWorkbench({
setCreating(true);
setCreateError(null);
try {
const response = await fetch('/api/courses', {
const response = await opsApiFetch('/api/courses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -127,7 +125,7 @@ export function LargeCourseWorkbench({
enableWebSearch: webSearch || undefined,
enableImageGeneration: enableImageGeneration || undefined,
enableVideoGeneration: enableVideoGeneration || undefined,
enableTTS: enableTTS || undefined,
enableTTS,
}),
});
const body = (await response.json()) as {
@@ -151,9 +149,7 @@ export function LargeCourseWorkbench({
> = {
image: { enabled: enableImageGeneration, onToggle: setEnableImageGeneration },
video: { enabled: enableVideoGeneration, onToggle: setEnableVideoGeneration },
// Narration is a large-course publishing requirement, so keep it visible
// in the shared settings bar without allowing it to be switched off.
tts: { enabled: enableTTS, disabled: true },
tts: { enabled: enableTTS, onToggle: setEnableTTS },
asr: { enabled: false, disabled: true },
};

View File

@@ -28,7 +28,7 @@ import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils';
import type { SceneOutline } from '@/lib/types/generation';
import type { WidgetType } from '@/lib/types/widgets';
import { changeOutlineType } from '@openmaic/generation';
import { changeOutlineType } from '@openmaic/generation/outline-type';
import { countBlockingOutlines, validateOutline } from '@/lib/edit/content-validation';
type SceneType = SceneOutline['type'];

View File

@@ -0,0 +1,10 @@
'use client';
import { useLayoutEffect } from 'react';
import { installOpsBrowserBoundary } from '@/lib/ops/api-fetch';
/** Base-path and Works-session adapter for the original operations UI. */
export function OpsBrowserBoundary() {
useLayoutEffect(() => installOpsBrowserBoundary(), []);
return null;
}

View File

@@ -31,7 +31,9 @@ export function InteractiveRenderer({ content, sceneId }: InteractiveRendererPro
const setActive = useInteractiveIframePool((s) => s.setActive);
const patchedHtml = useMemo(
() => (content.html ? patchHtmlForIframe(content.html) : undefined),
() => (content.html ? patchHtmlForIframe(content.html, {
offline: typeof window !== 'undefined' && window.__MAKELORE_OFFLINE_PLAYER__ === true,
}) : undefined),
[content.html],
);

View File

@@ -28,6 +28,7 @@ import { PBLV2SubmissionPanel, type SubmissionEvaluationStatus } from './submiss
import { PBLV2RightPanelTabs } from './right-panel-tabs';
import { shouldShowScenarioBriefing } from './scenario-briefing-gate';
import { cn } from '@/lib/utils/cn';
import { opsApiPath } from '@/lib/ops/api-fetch';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { CSSProperties } from 'react';
import { runOneStream, type StreamDisplayState, type StreamStatus } from './use-instructor-stream';
@@ -442,7 +443,7 @@ function WorkspaceTopBar({
<div className="relative flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-violet-200/25 bg-violet-100/[0.08] shadow-[0_0_24px_rgba(157,140,255,0.18)]">
<Image
src="/openmaic-mark.png"
src={opsApiPath('/openmaic-mark.png')}
alt="OpenMAIC"
width={28}
height={28}

View File

@@ -11,6 +11,7 @@ export function ServerProvidersInit() {
const fetchServerProviders = useSettingsStore((state) => state.fetchServerProviders);
useEffect(() => {
if (new URLSearchParams(window.location.search).get('embedded') === '1') return;
fetchServerProviders();
}, [fetchServerProviders]);

View File

@@ -20,6 +20,7 @@ import { useStageStore, useCanvasStore } from '@/lib/store';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { SceneType, SlideContent, InteractiveContent } from '@/lib/types/stage';
import { PENDING_SCENE_ID } from '@/lib/store/stage';
import { opsApiPath } from '@/lib/ops/api-fetch';
interface SceneSidebarProps {
readonly collapsed: boolean;
@@ -130,7 +131,7 @@ export function SceneSidebar({
className="flex items-center gap-2 cursor-pointer rounded-lg px-1.5 -mx-1.5 py-1 -my-1 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 active:scale-[0.97] transition-all duration-150"
title={t('generation.backToHome')}
>
<img src="/mailuo-logo.png" alt="麦洛学习" className="h-6" />
<img src={opsApiPath('/mailuo-logo.png')} alt="麦洛学习" className="h-6" />
</button>
<button
onClick={() => onCollapseChange(true)}