feat: productionize learning engine and classroom
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
10
OpenMAIC/components/ops-browser-boundary.tsx
Normal file
10
OpenMAIC/components/ops-browser-boundary.tsx
Normal 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;
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user