Files
openmaic/OpenMAIC/app/page.tsx

599 lines
25 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'motion/react';
import { ArrowUp, BookOpen, Hammer, Monitor, Moon, Settings, Sparkles, Sun } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { LanguageSwitcher } from '@/components/language-switcher';
import { createLogger } from '@/lib/logger';
import { cn } from '@/lib/utils';
import { SettingsDialog } from '@/components/settings';
import { GenerationToolbar } from '@/components/generation/generation-toolbar';
import { useTheme } from '@/lib/hooks/use-theme';
import { nanoid } from 'nanoid';
import { dedupeCourseMaterialFiles } from '@/lib/document/course-materials';
import type { SelectedCourseMaterial } from '@/lib/types/generation';
import { useSettingsStore } from '@/lib/store/settings';
import { hasUsableLLMProvider } from '@/lib/store/settings-validation';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { toast } from 'sonner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
import { SpeechButton } from '@/components/audio/speech-button';
import { shouldShowVocationalTestUi } from '@/lib/config/feature-flags';
import { InteractiveModeButton } from '@/components/generation/interactive-mode-button';
import {
GenerationModeTabs,
type GenerationMode,
} from '@/components/generation/generation-mode-tabs';
import { LargeCourseWorkbench } from '@/components/generation/large-course-workbench';
import { opsApiPath } from '@/lib/ops/api-fetch';
import { storeDocumentBlob } from '@/lib/utils/image-storage';
import { createForegroundGenerationSession } from '@/lib/generation/foreground-session';
import { isOpsWorkbenchEnabled, isSettingsManagementEnabled } from '@/lib/config/deployment-role';
const log = createLogger('Home');
const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled';
interface FormState {
courseMaterials: SelectedCourseMaterial[];
requirement: string;
webSearch: boolean;
interactiveMode: boolean;
vocationalTestMode: boolean;
}
const initialFormState: FormState = {
courseMaterials: [],
requirement: '',
webSearch: false,
interactiveMode: false,
vocationalTestMode: false,
};
function HomePage() {
const { t } = useI18n();
const { theme, setTheme } = useTheme();
const router = useRouter();
const showVocationalTestUi = shouldShowVocationalTestUi();
const [form, setForm] = useState<FormState>(initialFormState);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] = useState<
import('@/lib/types/settings').SettingsSection | undefined
>(undefined);
const [generationMode, setGenerationMode] = useState<GenerationMode>('normal');
// Draft cache for requirement text
const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } =
useDraftCache<string>({ key: 'requirementDraft' });
// A usable LLM provider exists ⇒ a concrete model is always selected (#580
// invariant). Gate generation on this single condition (state A vs B)
// instead of inspecting modelId directly.
const providersConfig = useSettingsStore((s) => s.providersConfig);
const hasUsableProvider = hasUsableLLMProvider(providersConfig);
const [themeOpen, setThemeOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const toolbarRef = useRef<HTMLDivElement>(null);
const opsWorkbenchEnabled = isOpsWorkbenchEnabled();
const settingsManagementEnabled = isSettingsManagementEnabled();
// Hydrate client-only state after mount (avoids SSR mismatch)
useEffect(() => {
try {
const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY);
const savedInteractiveMode = localStorage.getItem(INTERACTIVE_MODE_STORAGE_KEY);
const updates: Partial<FormState> = {};
if (savedWebSearch === 'true') updates.webSearch = true;
if (savedInteractiveMode === 'true') updates.interactiveMode = true;
if (Object.keys(updates).length > 0) {
setForm((prev) => ({ ...prev, ...updates }));
}
} catch {
/* localStorage unavailable */
}
}, []);
// Restore requirement draft from localStorage on mount. The previous derived-state
// pattern initialised `prev` from the cached value itself, so on the first client
// render the comparison was always equal and the restore never fired. Use an effect
// so the cache is hydrated into the form once we know the live requirement is empty.
const draftRestoredRef = useRef(false);
useEffect(() => {
if (draftRestoredRef.current) return;
if (!cachedRequirement) return;
draftRestoredRef.current = true;
setForm((prev) => (prev.requirement ? prev : { ...prev, requirement: cachedRequirement }));
}, [cachedRequirement]);
// Close dropdowns when clicking outside
useEffect(() => {
if (!themeOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
setThemeOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [themeOpen]);
useEffect(() => {
// Clear stale media store to prevent cross-course thumbnail contamination.
// The store may hold tasks from a previously visited classroom whose elementIds
// (gen_img_1, etc.) collide with other courses' placeholders.
useMediaGenerationStore.getState().revokeObjectUrls();
useMediaGenerationStore.setState({ tasks: {} });
}, []);
const updateForm = <K extends keyof FormState>(field: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [field]: value }));
try {
if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value));
if (field === 'interactiveMode')
localStorage.setItem(INTERACTIVE_MODE_STORAGE_KEY, String(value));
if (field === 'requirement') updateRequirementCache(value as string);
} catch {
/* ignore */
}
};
const addCourseMaterials = (files: File[]) => {
setForm((prev) => {
const dedupedFiles = dedupeCourseMaterialFiles(prev.courseMaterials, files);
const startOrder = prev.courseMaterials.length + 1;
const additions = dedupedFiles.map((file, index) => ({
id: nanoid(8),
file,
name: file.name,
size: file.size,
lastModified: file.lastModified,
type: file.type,
order: startOrder + index,
}));
return additions.length > 0
? { ...prev, courseMaterials: [...prev.courseMaterials, ...additions] }
: prev;
});
};
const removeCourseMaterial = (id: string) => {
setForm((prev) => ({
...prev,
courseMaterials: prev.courseMaterials
.filter((item) => item.id !== id)
.map((item, index) => ({ ...item, order: index + 1 })),
}));
};
const usesForegroundGeneration =
form.courseMaterials.length > 0 || form.interactiveMode || form.vocationalTestMode;
// Plain requirements use the durable server-side task flow. Features that
// still need browser-owned files or the original interactive/vocational
// outline pipeline are handed to the existing generation-preview flow.
const startGeneration = async () => {
if (!form.requirement.trim()) {
setError(t('upload.requirementRequired'));
return;
}
setError(null);
if (isSubmitting) return;
setIsSubmitting(true);
try {
if (usesForegroundGeneration) {
const settings = useSettingsStore.getState();
const pdfProviderConfig = settings.pdfProvidersConfig[settings.pdfProviderId];
const session = await createForegroundGenerationSession({
sessionId: nanoid(10),
requirement: form.requirement,
webSearch: form.webSearch,
interactiveMode: form.interactiveMode,
taskEngineMode: form.vocationalTestMode,
courseMaterials: form.courseMaterials,
pdfProviderId: settings.pdfProviderId,
pdfProviderConfig,
storeDocument: storeDocumentBlob,
});
sessionStorage.setItem('generationSession', JSON.stringify(session));
router.push('/generation-preview');
return;
}
const response = await fetch('/api/generate-classroom', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requirement: form.requirement,
...(form.webSearch ? { enableWebSearch: true } : {}),
}),
});
const body = (await response.json()) as {
success?: boolean;
jobId?: string;
error?: string;
details?: string;
};
if (!response.ok || !body.success || !body.jobId) {
throw new Error(body.details ?? body.error ?? `HTTP ${response.status}`);
}
toast.success(t('jobs.started'), {
action: {
label: t('jobs.viewTasks'),
onClick: () => router.push('/tasks'),
},
});
} catch (err) {
log.error('Failed to start generation:', err);
setError(err instanceof Error ? err.message : t('upload.generateFailed'));
} finally {
setIsSubmitting(false);
}
};
const canGenerate = !!form.requirement.trim() && hasUsableProvider && !isSubmitting;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
if (canGenerate) void startGeneration();
}
};
return (
<div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex flex-col items-center p-4 pt-16 md:p-8 md:pt-16 overflow-x-hidden">
{/* ═══ Top-right pill ═══ */}
<div
ref={toolbarRef}
className="fixed top-4 right-4 z-50 flex items-center gap-1 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md px-2 py-1.5 rounded-full border border-gray-100/50 dark:border-gray-700/50 shadow-sm"
>
{/* Language Selector */}
<LanguageSwitcher onOpen={() => setThemeOpen(false)} />
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
{/* Theme Selector */}
<div className="relative">
<button
onClick={() => {
setThemeOpen(!themeOpen);
}}
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
>
{theme === 'light' && <Sun className="w-4 h-4" />}
{theme === 'dark' && <Moon className="w-4 h-4" />}
{theme === 'system' && <Monitor className="w-4 h-4" />}
</button>
{themeOpen && (
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[140px]">
<button
onClick={() => {
setTheme('light');
setThemeOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
theme === 'light' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
<Sun className="w-4 h-4" />
{t('settings.themeOptions.light')}
</button>
<button
onClick={() => {
setTheme('dark');
setThemeOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
theme === 'dark' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
<Moon className="w-4 h-4" />
{t('settings.themeOptions.dark')}
</button>
<button
onClick={() => {
setTheme('system');
setThemeOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
theme === 'system' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
<Monitor className="w-4 h-4" />
{t('settings.themeOptions.system')}
</button>
</div>
)}
</div>
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
{/* Published course catalog — available on every learner surface. */}
<button
onClick={() => router.push('/learn')}
title={t('learner.openCatalog')}
className="flex items-center gap-1.5 rounded-full px-2 py-2 text-gray-400 transition-all hover:bg-white hover:text-violet-600 hover:shadow-sm dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-violet-400"
>
<BookOpen className="w-4 h-4" />
<span className="hidden text-xs font-medium sm:inline">{t('learner.openCatalog')}</span>
</button>
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
{/* Generation tasks — managed on the dedicated /tasks page */}
<button
onClick={() => router.push('/tasks')}
title={t('jobs.title')}
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
>
<Hammer className="w-4 h-4 group-hover:scale-110 transition-transform" />
</button>
{settingsManagementEnabled && (
<>
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
{/* Settings are operator-managed; learner deployments do not expose this control. */}
<div className="relative">
<button
onClick={() => setSettingsOpen(true)}
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
>
<Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" />
</button>
</div>
</>
)}
</div>
{settingsManagementEnabled && (
<SettingsDialog
open={settingsOpen}
onOpenChange={(open) => {
setSettingsOpen(open);
if (!open) setSettingsSection(undefined);
}}
initialSection={settingsSection}
/>
)}
{/* ═══ Background Decor ═══ */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div
className="absolute top-0 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse"
style={{ animationDuration: '4s' }}
/>
<div
className="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse"
style={{ animationDuration: '6s' }}
/>
</div>
{/* ═══ Hero section: title + input (centered, wider) ═══ */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className={cn('relative z-20 w-full max-w-[800px] flex flex-col items-center mt-[10vh]')}
>
{/* ── Logo ── */}
<motion.img
src={opsApiPath('/mailuo-logo.png')}
alt="麦洛学习"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
delay: 0.1,
type: 'spring',
stiffness: 200,
damping: 20,
}}
className="h-12 md:h-16 mb-2 -ml-2 md:-ml-3"
/>
{/* ── Slogan ── */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25 }}
className="text-sm text-muted-foreground/60 mb-8"
>
{t('home.slogan')}
</motion.p>
<div className="mb-6">
<GenerationModeTabs
value={generationMode}
onChange={setGenerationMode}
showLarge={opsWorkbenchEnabled}
/>
</div>
{generationMode === 'normal' ? (
<>
{/* ── Unified input area ── */}
<motion.div
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.35 }}
className="w-full"
>
<div className="w-full rounded-2xl border border-border/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl shadow-xl shadow-black/[0.03] dark:shadow-black/20 transition-shadow focus-within:shadow-2xl focus-within:shadow-violet-500/[0.06]">
{/* Textarea */}
<textarea
placeholder={t('upload.requirementPlaceholder')}
className="w-full resize-none border-0 bg-transparent px-4 pt-1 pb-2 text-[13px] leading-relaxed placeholder:text-muted-foreground/40 focus:outline-none min-h-[140px] max-h-[300px]"
value={form.requirement}
onChange={(e) => updateForm('requirement', e.target.value)}
onKeyDown={handleKeyDown}
rows={4}
/>
{/* Toolbar row */}
<div className="px-3 pb-3 flex items-end gap-2">
<div className="flex-1 min-w-0">
<GenerationToolbar
webSearch={form.webSearch}
onWebSearchChange={(v) => updateForm('webSearch', v)}
onSettingsOpen={(section) => {
if (!settingsManagementEnabled) return;
setSettingsSection(section);
setSettingsOpen(true);
}}
courseMaterials={form.courseMaterials}
onCourseMaterialsAdd={addCourseMaterials}
onCourseMaterialRemove={removeCourseMaterial}
onPdfError={setError}
/>
</div>
{/* Interactive mode toggle */}
<Tooltip>
<TooltipTrigger asChild>
<InteractiveModeButton
pressed={form.interactiveMode}
label={t('toolbar.interactiveModeLabel')}
onPressedChange={(pressed) => updateForm('interactiveMode', pressed)}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{t('toolbar.interactiveModeHint')}
</TooltipContent>
</Tooltip>
{/* Voice input */}
<SpeechButton
size="md"
onTranscription={(text) => {
setForm((prev) => {
const next = prev.requirement + (prev.requirement ? ' ' : '') + text;
updateRequirementCache(next);
return { ...prev, requirement: next };
});
}}
/>
{/* Send button — background for plain prompts, foreground for
materials and interactive/vocational generation. */}
<button
onClick={() => void startGeneration()}
disabled={!canGenerate}
title={t(
usesForegroundGeneration ? 'jobs.foregroundHint' : 'jobs.backgroundHint',
)}
className={cn(
'shrink-0 h-8 rounded-lg flex items-center justify-center gap-1.5 transition-all px-3',
canGenerate
? 'bg-primary text-primary-foreground hover:opacity-90 shadow-sm cursor-pointer'
: 'bg-muted text-muted-foreground/40 cursor-not-allowed',
)}
>
<span className="text-xs font-medium">
{t(
usesForegroundGeneration
? 'jobs.generateForeground'
: 'jobs.generateBackground',
)}
</span>
<ArrowUp className="size-3.5" />
</button>
</div>
</div>
</motion.div>
{showVocationalTestUi && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-2 flex w-full justify-start px-1"
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
role="switch"
aria-checked={form.vocationalTestMode}
onClick={() => updateForm('vocationalTestMode', !form.vocationalTestMode)}
className={cn(
'inline-flex h-7 items-center gap-2 rounded-full border px-2.5 text-[11px] font-medium transition-colors',
form.vocationalTestMode
? 'border-cyan-400/70 bg-cyan-50 text-cyan-700 shadow-[0_0_10px_rgba(6,182,212,0.16)] dark:bg-cyan-950/40 dark:text-cyan-300'
: 'border-border/70 bg-background/70 text-muted-foreground hover:border-cyan-300/60 hover:text-cyan-700 dark:hover:text-cyan-300',
)}
>
<span className="rounded-full bg-cyan-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-normal text-cyan-700 dark:bg-cyan-900/45 dark:text-cyan-300">
测试功能
</span>
<Sparkles className="size-3.5" />
<span>职教任务</span>
<span
className={cn(
'relative h-3.5 w-6 rounded-full transition-colors',
form.vocationalTestMode ? 'bg-cyan-500' : 'bg-muted-foreground/25',
)}
>
<span
className={cn(
'absolute left-0.5 top-0.5 size-2.5 rounded-full bg-white transition-transform',
form.vocationalTestMode ? 'translate-x-2.5' : 'translate-x-0',
)}
/>
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
从当前输入框提交职教实操训练测试
</TooltipContent>
</Tooltip>
</motion.div>
)}
{/* ── Error ── */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 w-full p-3 bg-destructive/10 border border-destructive/20 rounded-lg"
>
<p className="text-sm text-destructive">{error}</p>
</motion.div>
)}
</AnimatePresence>
</>
) : (
<LargeCourseWorkbench
embedded
onSettingsOpen={(section) => {
if (!settingsManagementEnabled) return;
setSettingsSection(section);
setSettingsOpen(true);
}}
/>
)}
</motion.div>
{/* Footer — flows with content, at the very end */}
<div className="mt-auto pt-12 pb-4 text-center text-xs text-muted-foreground/40">
麦洛学习
</div>
</div>
);
}
export default function Page() {
return <HomePage />;
}