From 0d20abfed9602db14323225482ae0cd505311604 Mon Sep 17 00:00:00 2001 From: wyuc Date: Thu, 12 Mar 2026 16:41:07 +0800 Subject: [PATCH] Initial commit: OpenMAIC AI classroom platform Next.js 16 App Router application with: - AI-powered interactive classroom generation from PDF/text requirements - Multi-agent discussion system (teacher, students, assistant roles) - Provider abstraction for LLM, TTS, ASR, image, video, web search - Local-first data architecture (IndexedDB/Dexie) - i18n support (zh-CN/en-US) - Structured logger, SSRF guard, lint/build passing (0 errors) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 55 + .prettierrc | 16 + app/api/azure-voices/route.ts | 67 + app/api/chat/route.ts | 179 + app/api/generate-classroom/route.ts | 252 + app/api/generate/agent-profiles/route.ts | 177 + app/api/generate/image/route.ts | 77 + app/api/generate/route.ts | 120 + app/api/generate/scene-actions/route.ts | 173 + app/api/generate/scene-content/route.ts | 173 + .../generate/scene-outlines-stream/route.ts | 322 + app/api/generate/scene-outlines/route.ts | 83 + app/api/generate/scene/route.ts | 191 + app/api/generate/tts/route.ts | 70 + app/api/generate/video/route.ts | 77 + app/api/parse-pdf/route.ts | 69 + app/api/pbl/chat/route.ts | 87 + app/api/proxy-media/route.ts | 60 + app/api/quiz-grade/route.ts | 113 + app/api/server-providers/route.ts | 30 + app/api/transcription/route.ts | 52 + app/api/verify-image-provider/route.ts | 56 + app/api/verify-model/route.ts | 72 + app/api/verify-video-provider/route.ts | 56 + app/api/web-search/route.ts | 51 + app/apple-icon.png | Bin 0 -> 24341 bytes app/classroom/[id]/page.tsx | 157 + app/favicon.ico | Bin 0 -> 7482 bytes app/generation-preview/layout.tsx | 10 + app/generation-preview/page.tsx | 1652 ++ app/globals.css | 209 + app/layout.tsx | 50 + app/page.tsx | 978 + components.json | 26 + components/agent-avatar.tsx | 60 + components/agent-bar.tsx | 277 + components/agent-config-panel.tsx | 159 + components/agent-reveal-modal.tsx | 353 + components/ai-elements/artifact.tsx | 147 + components/ai-elements/canvas.tsx | 22 + components/ai-elements/chain-of-thought.tsx | 231 + components/ai-elements/checkpoint.tsx | 67 + components/ai-elements/code-block.tsx | 178 + components/ai-elements/confirmation.tsx | 176 + components/ai-elements/connection.tsx | 28 + components/ai-elements/context.tsx | 408 + components/ai-elements/controls.tsx | 18 + components/ai-elements/conversation.tsx | 100 + components/ai-elements/edge.tsx | 140 + components/ai-elements/image.tsx | 23 + components/ai-elements/inline-citation.tsx | 291 + components/ai-elements/loader.tsx | 96 + components/ai-elements/message.tsx | 451 + components/ai-elements/model-selector.tsx | 205 + components/ai-elements/node.tsx | 71 + components/ai-elements/open-in-chat.tsx | 365 + components/ai-elements/panel.tsx | 15 + components/ai-elements/plan.tsx | 142 + components/ai-elements/prompt-input.tsx | 1415 ++ components/ai-elements/queue.tsx | 274 + components/ai-elements/reasoning.tsx | 181 + components/ai-elements/shimmer.tsx | 73 + components/ai-elements/sources.tsx | 77 + components/ai-elements/suggestion.tsx | 56 + components/ai-elements/task.tsx | 87 + components/ai-elements/tool.tsx | 163 + components/ai-elements/toolbar.tsx | 16 + components/ai-elements/web-preview.tsx | 263 + components/canvas-area.tsx | 219 + components/canvas-toolbar.tsx | 358 + components/chat/chat-area.tsx | 283 + components/chat/chat-session.tsx | 323 + components/chat/inline-action-tag.tsx | 103 + components/chat/lecture-notes-view.tsx | 173 + components/chat/process-sse-stream.ts | 113 + components/chat/session-list.tsx | 135 + components/chat/use-chat-sessions.ts | 1293 + components/generation-toolbar.tsx | 538 + components/generation/generating-progress.tsx | 141 + components/generation/outlines-editor.tsx | 288 + components/header.tsx | 270 + components/media-popover.tsx | 504 + components/proactive-card.tsx | 205 + components/roundtable/index.tsx | 1128 + .../scene-renderers/interactive-renderer.tsx | 76 + components/scene-renderers/pbl-renderer.tsx | 117 + components/scene-renderers/pbl/chat-panel.tsx | 165 + components/scene-renderers/pbl/guide.tsx | 125 + .../scene-renderers/pbl/issueboard-panel.tsx | 83 + .../scene-renderers/pbl/role-selection.tsx | 59 + .../scene-renderers/pbl/use-pbl-chat.ts | 259 + components/scene-renderers/pbl/workspace.tsx | 84 + components/scene-renderers/quiz-renderer.tsx | 88 + components/scene-renderers/quiz-view.tsx | 886 + components/server-providers-init.tsx | 20 + components/settings/add-provider-dialog.tsx | 185 + components/settings/agent-settings.tsx | 201 + components/settings/asr-settings.tsx | 252 + components/settings/audio-settings.tsx | 1016 + components/settings/general-settings.tsx | 181 + components/settings/image-settings.tsx | 339 + components/settings/index.tsx | 990 + components/settings/model-edit-dialog.tsx | 391 + components/settings/model-selector.tsx | 429 + components/settings/pdf-settings.tsx | 133 + components/settings/provider-config-panel.tsx | 429 + components/settings/provider-list.tsx | 88 + components/settings/tts-settings.tsx | 245 + components/settings/utils.ts | 28 + components/settings/video-settings.tsx | 340 + components/settings/web-search-settings.tsx | 103 + .../Editor/Canvas/AlignmentLine.tsx | 36 + .../Editor/Canvas/EditableElement.tsx | 236 + .../Editor/Canvas/ElementCreateSelection.tsx | 194 + .../Editor/Canvas/GridLines.tsx | 55 + .../Editor/Canvas/MouseSelection.tsx | 25 + .../Editor/Canvas/Operate/BorderLine.tsx | 28 + .../Canvas/Operate/CommonElementOperate.tsx | 62 + .../Canvas/Operate/ImageElementOperate.tsx | 59 + .../Canvas/Operate/LineElementOperate.tsx | 136 + .../Canvas/Operate/MultiSelectOperate.tsx | 78 + .../Editor/Canvas/Operate/ResizeHandler.tsx | 80 + .../Editor/Canvas/Operate/RotateHandler.tsx | 15 + .../Canvas/Operate/ShapeElementOperate.tsx | 98 + .../Canvas/Operate/TableElementOperate.tsx | 59 + .../Canvas/Operate/TextElementOperate.tsx | 61 + .../Editor/Canvas/Operate/index.tsx | 151 + .../slide-renderer/Editor/Canvas/Ruler.tsx | 121 + .../Editor/Canvas/ShapeCreateCanvas.tsx | 182 + .../Editor/Canvas/ViewportBackground.tsx | 30 + .../Editor/Canvas/hooks/useCommonOperate.ts | 50 + .../Editor/Canvas/hooks/useDragElement.ts | 353 + .../Editor/Canvas/hooks/useDragLineElement.ts | 264 + .../Editor/Canvas/hooks/useDrop.ts | 45 + .../hooks/useInsertFromCreateSelection.ts | 99 + .../Editor/Canvas/hooks/useMouseSelection.ts | 185 + .../Canvas/hooks/useMoveShapeKeypoint.ts | 139 + .../Editor/Canvas/hooks/useRotateElement.ts | 126 + .../Editor/Canvas/hooks/useScaleElement.ts | 624 + .../Editor/Canvas/hooks/useSelectElement.ts | 121 + .../Editor/Canvas/hooks/useViewportSize.ts | 165 + .../slide-renderer/Editor/Canvas/index.tsx | 372 + .../Editor/CanvasTool/index.tsx | 251 + .../Editor/HighlightOverlay.tsx | 117 + .../slide-renderer/Editor/LaserOverlay.tsx | 74 + .../slide-renderer/Editor/ScreenCanvas.tsx | 114 + .../slide-renderer/Editor/ScreenElement.tsx | 67 + .../Editor/SpotlightOverlay.tsx | 168 + .../slide-renderer/Editor/ZoomWrapper.tsx | 47 + components/slide-renderer/Editor/index.tsx | 24 + .../ThumbnailSlide/ThumbnailElement.tsx | 55 + .../components/ThumbnailSlide/index.tsx | 87 + .../element/ChartElement/BaseChartElement.tsx | 55 + .../components/element/ChartElement/Chart.tsx | 113 + .../element/ChartElement/chartOption.ts | 315 + .../components/element/ChartElement/index.tsx | 68 + .../components/element/ElementOutline.tsx | 39 + .../element/ImageElement/BaseImageElement.tsx | 145 + .../element/ImageElement/ImageClipHandler.tsx | 649 + .../ImageOutline/image-ellipse-outline.tsx | 41 + .../ImageOutline/image-polygon-outline.tsx | 44 + .../ImageOutline/image-rect-outline.tsx | 47 + .../ImageElement/ImageOutline/index.tsx | 46 + .../components/element/ImageElement/index.tsx | 153 + .../element/ImageElement/useClipImage.ts | 57 + .../element/ImageElement/useFilter.ts | 21 + .../element/LatexElement/BaseLatexElement.tsx | 100 + .../components/element/LatexElement/index.tsx | 105 + .../element/LineElement/BaseLineElement.tsx | 146 + .../element/LineElement/LinePointMarker.tsx | 45 + .../components/element/LineElement/index.tsx | 122 + .../components/element/ProsemirrorEditor.tsx | 392 + .../element/ShapeElement/BaseShapeElement.tsx | 118 + .../element/ShapeElement/GradientDefs.tsx | 35 + .../element/ShapeElement/PatternDefs.tsx | 18 + .../components/element/ShapeElement/index.tsx | 195 + .../element/TableElement/BaseTableElement.tsx | 35 + .../element/TableElement/StaticTable.tsx | 121 + .../components/element/TableElement/index.tsx | 51 + .../element/TableElement/tableUtils.ts | 69 + .../element/TextElement/BaseTextElement.tsx | 63 + .../components/element/TextElement/index.tsx | 196 + .../element/VideoElement/BaseVideoElement.tsx | 173 + .../components/element/VideoElement/index.tsx | 77 + .../element/hooks/useElementFill.ts | 20 + .../element/hooks/useElementFlip.ts | 23 + .../element/hooks/useElementOutline.ts | 31 + .../element/hooks/useElementShadow.ts | 21 + components/speech-button.tsx | 130 + components/stage.tsx | 869 + components/stage/scene-renderer.tsx | 54 + components/stage/scene-sidebar.tsx | 377 + components/tts-config-popover.tsx | 179 + components/ui/alert-dialog.tsx | 184 + components/ui/alert.tsx | 72 + components/ui/avatar.tsx | 109 + components/ui/badge.tsx | 45 + components/ui/button-group.tsx | 83 + components/ui/button.tsx | 60 + components/ui/card.tsx | 94 + components/ui/carousel.tsx | 244 + components/ui/checkbox.tsx | 30 + components/ui/collapsible.tsx | 33 + components/ui/combobox.tsx | 294 + components/ui/command.tsx | 189 + components/ui/context-menu.tsx | 249 + components/ui/dialog.tsx | 164 + components/ui/dropdown-menu.tsx | 255 + components/ui/field.tsx | 227 + components/ui/hover-card.tsx | 44 + components/ui/input-group.tsx | 147 + components/ui/input.tsx | 19 + components/ui/label.tsx | 24 + components/ui/popover.tsx | 31 + components/ui/progress.tsx | 31 + components/ui/scroll-area.tsx | 55 + components/ui/select.tsx | 185 + components/ui/separator.tsx | 28 + components/ui/slider.tsx | 28 + components/ui/sonner.tsx | 49 + components/ui/switch.tsx | 29 + components/ui/tabs.tsx | 90 + components/ui/textarea.tsx | 18 + components/ui/tooltip.tsx | 61 + components/user-profile.tsx | 226 + components/whiteboard/index.tsx | 141 + components/whiteboard/whiteboard-canvas.tsx | 164 + configs/animation.ts | 234 + configs/chart.ts | 70 + configs/element.ts | 22 + configs/font.ts | 31 + configs/hotkey.ts | 149 + configs/image-clip.ts | 181 + configs/latex.ts | 274 + configs/lines.ts | 39 + configs/mime.ts | 26 + configs/shapes.ts | 1033 + configs/storage.ts | 1 + configs/symbol.ts | 72 + configs/theme.ts | 126 + eslint.config.mjs | 38 + lib/action/engine.ts | 503 + lib/ai/llm.ts | 329 + lib/ai/providers.ts | 882 + lib/ai/thinking-context.ts | 23 + lib/api/stage-api.ts | 1589 ++ lib/audio/asr-providers.ts | 361 + lib/audio/azure.json | 19729 ++++++++++++++++ lib/audio/constants.ts | 780 + lib/audio/qwen.html | 1 + lib/audio/tts-providers.ts | 365 + lib/audio/types.ts | 177 + lib/buffer/stream-buffer.ts | 594 + lib/chat/action-translations.ts | 87 + lib/constants/generation.ts | 10 + lib/export/html-parser/format.ts | 47 + lib/export/html-parser/index.ts | 15 + lib/export/html-parser/lexer.ts | 274 + lib/export/html-parser/parser.ts | 129 + lib/export/html-parser/stringify.ts | 28 + lib/export/html-parser/tags.ts | 16 + lib/export/html-parser/types.ts | 69 + lib/export/latex-to-omml.ts | 82 + lib/export/svg-arc-to-cubic-bezier.d.ts | 24 + lib/export/svg-path-parser.ts | 146 + lib/export/svg2base64.ts | 55 + lib/export/use-export-pptx.ts | 1084 + lib/generation/action-parser.ts | 144 + lib/generation/generation-pipeline.ts | 1984 ++ lib/generation/interactive-post-processor.ts | 150 + lib/generation/prompts/index.ts | 33 + lib/generation/prompts/loader.ts | 135 + .../prompts/snippets/action-types.md | 27 + .../prompts/snippets/element-types.md | 37 + .../prompts/snippets/json-output-rules.md | 6 + .../templates/interactive-actions/system.md | 43 + .../templates/interactive-actions/user.md | 12 + .../templates/interactive-html/system.md | 50 + .../templates/interactive-html/user.md | 46 + .../interactive-scientific-model/system.md | 37 + .../interactive-scientific-model/user.md | 31 + .../prompts/templates/pbl-actions/system.md | 36 + .../prompts/templates/pbl-actions/user.md | 14 + .../prompts/templates/quiz-actions/system.md | 77 + .../prompts/templates/quiz-actions/user.md | 11 + .../prompts/templates/quiz-content/system.md | 136 + .../prompts/templates/quiz-content/user.md | 9 + .../requirements-to-outlines/system.md | 279 + .../requirements-to-outlines/user.md | 78 + .../prompts/templates/slide-actions/system.md | 128 + .../prompts/templates/slide-actions/user.md | 12 + .../prompts/templates/slide-content/system.md | 906 + .../prompts/templates/slide-content/user.md | 32 + lib/generation/prompts/types.ts | 34 + lib/hooks/use-audio-recorder.ts | 287 + lib/hooks/use-browser-asr.ts | 145 + lib/hooks/use-browser-tts.ts | 150 + lib/hooks/use-canvas-operations.ts | 567 + lib/hooks/use-draft-cache.ts | 95 + lib/hooks/use-history-snapshot.ts | 41 + lib/hooks/use-order-element.ts | 180 + lib/hooks/use-scene-generator.ts | 478 + lib/hooks/use-slide-background-style.ts | 52 + lib/hooks/use-streaming-text.ts | 127 + lib/i18n/chat.ts | 193 + lib/i18n/common.ts | 149 + lib/i18n/generation.ts | 383 + lib/i18n/index.ts | 14 + lib/i18n/settings.ts | 1141 + lib/i18n/stage.ts | 375 + lib/i18n/types.ts | 3 + lib/logger.ts | 41 + lib/media/adapters/kling-adapter.ts | 255 + lib/media/adapters/nano-banana-adapter.ts | 145 + lib/media/adapters/qwen-image-adapter.ts | 133 + lib/media/adapters/seedance-adapter.ts | 238 + lib/media/adapters/seedream-adapter.ts | 112 + lib/media/adapters/veo-adapter.ts | 235 + lib/media/image-providers.ts | 98 + lib/media/media-orchestrator.ts | 278 + lib/media/types.ts | 329 + lib/media/video-providers.ts | 142 + lib/orchestration/ai-sdk-adapter.ts | 151 + lib/orchestration/director-graph.ts | 499 + lib/orchestration/director-prompt.ts | 264 + lib/orchestration/prompt-builder.ts | 816 + lib/orchestration/registry/store.ts | 381 + lib/orchestration/registry/types.ts | 83 + lib/orchestration/stateless-generate.ts | 411 + lib/orchestration/tool-schemas.ts | 53 + lib/pbl/generate-pbl.ts | 405 + lib/pbl/mcp/agent-mcp.ts | 117 + lib/pbl/mcp/agent-templates.ts | 99 + lib/pbl/mcp/issueboard-mcp.ts | 227 + lib/pbl/mcp/mode-mcp.ts | 36 + lib/pbl/mcp/project-mcp.ts | 40 + lib/pbl/pbl-system-prompt.ts | 150 + lib/pbl/types.ts | 79 + lib/pdf/README.md | 345 + lib/pdf/constants.ts | 43 + lib/pdf/pdf-providers.ts | 465 + lib/pdf/types.ts | 31 + lib/playback/derived-state.ts | 212 + lib/playback/engine.ts | 512 + lib/playback/index.ts | 3 + lib/playback/types.ts | 62 + lib/prosemirror/commands/replaceText.ts | 33 + lib/prosemirror/commands/setListStyle.ts | 29 + lib/prosemirror/commands/setTextAlign.ts | 62 + lib/prosemirror/commands/setTextIndent.ts | 87 + lib/prosemirror/commands/toggleList.ts | 52 + lib/prosemirror/index.ts | 32 + lib/prosemirror/plugins/index.ts | 31 + lib/prosemirror/plugins/inputrules.ts | 63 + lib/prosemirror/plugins/keymap.ts | 47 + lib/prosemirror/plugins/placeholder.ts | 23 + lib/prosemirror/schema/index.ts | 5 + lib/prosemirror/schema/marks.ts | 194 + lib/prosemirror/schema/nodes.ts | 178 + lib/prosemirror/utils.ts | 260 + lib/server/api-response.ts | 43 + lib/server/provider-config.ts | 382 + lib/server/proxy-fetch.ts | 73 + lib/server/ssrf-guard.ts | 49 + lib/storage/index.ts | 13 + lib/storage/providers/noop.ts | 17 + lib/storage/types.ts | 12 + lib/store/canvas.ts | 466 + lib/store/index.ts | 18 + lib/store/keyboard.ts | 33 + lib/store/media-generation.ts | 215 + lib/store/media-stage-context.tsx | 18 + lib/store/scene-context.tsx | 203 + lib/store/settings.ts | 984 + lib/store/snapshot.ts | 165 + lib/store/stage.ts | 329 + lib/store/user-profile.ts | 44 + lib/types/action.ts | 210 + lib/types/chat.ts | 298 + lib/types/edit.ts | 126 + lib/types/export.ts | 1 + lib/types/generation.ts | 228 + lib/types/pdf.ts | 76 + lib/types/provider.ts | 103 + lib/types/roundtable.ts | 28 + lib/types/settings.ts | 40 + lib/types/slides.ts | 799 + lib/types/stage.ts | 128 + lib/types/toolbar.ts | 9 + lib/types/web-search.ts | 13 + lib/use-i18n.tsx | 65 + lib/use-theme.tsx | 75 + lib/utils.ts | 6 + lib/utils/audio-player.ts | 187 + lib/utils/chat-storage.ts | 84 + lib/utils/create-selectors.ts | 17 + lib/utils/database.ts | 422 + lib/utils/element.ts | 262 + lib/utils/emitter.ts | 29 + lib/utils/geometry.ts | 115 + lib/utils/image-storage.ts | 177 + lib/utils/model-config.ts | 23 + lib/utils/playback-storage.ts | 56 + lib/utils/stage-storage.ts | 234 + lib/web-search/constants.ts | 24 + lib/web-search/tavily.ts | 92 + lib/web-search/types.ts | 19 + next.config.ts | 11 + package.json | 117 + packages/mathml2omml/.gitignore | 3 + packages/mathml2omml/LICENSE | 165 + packages/mathml2omml/package.json | 44 + packages/mathml2omml/rollup.config.js | 28 + packages/mathml2omml/src/helpers.js | 15 + packages/mathml2omml/src/index.d.ts | 40 + packages/mathml2omml/src/index.js | 28 + packages/mathml2omml/src/mathml/index.js | 18 + packages/mathml2omml/src/mathml/math.js | 15 + packages/mathml2omml/src/mathml/menclose.js | 114 + packages/mathml2omml/src/mathml/mfrac.js | 54 + packages/mathml2omml/src/mathml/mglyph.js | 9 + .../mathml2omml/src/mathml/mmultiscripts.js | 127 + packages/mathml2omml/src/mathml/mroot.js | 55 + packages/mathml2omml/src/mathml/mrow.js | 32 + packages/mathml2omml/src/mathml/mspace.js | 22 + packages/mathml2omml/src/mathml/msqrt.js | 38 + packages/mathml2omml/src/mathml/mstyle.js | 4 + packages/mathml2omml/src/mathml/msub.js | 108 + packages/mathml2omml/src/mathml/msubsup.js | 117 + packages/mathml2omml/src/mathml/msup.js | 112 + packages/mathml2omml/src/mathml/munderover.js | 101 + packages/mathml2omml/src/mathml/table.js | 96 + packages/mathml2omml/src/mathml/text.js | 25 + .../mathml2omml/src/mathml/text_container.js | 136 + packages/mathml2omml/src/mathml/text_style.js | 107 + .../mathml2omml/src/mathml/under_or_over.js | 269 + packages/mathml2omml/src/ooml/index.js | 2 + packages/mathml2omml/src/ooml/nary.js | 48 + packages/mathml2omml/src/ooml/scriptlevel.js | 19 + .../mathml2omml/src/parse-stringify/index.js | 4 + .../src/parse-stringify/parse-tag.js | 58 + .../mathml2omml/src/parse-stringify/parse.js | 113 + .../src/parse-stringify/stringify.js | 37 + packages/mathml2omml/src/walker.js | 102 + packages/pptxgenjs/.gitignore | 5 + packages/pptxgenjs/package.json | 96 + packages/pptxgenjs/rollup.config.mjs | 30 + packages/pptxgenjs/src/core-enums.ts | 772 + packages/pptxgenjs/src/core-interfaces.ts | 1901 ++ packages/pptxgenjs/src/gen-charts.ts | 2042 ++ packages/pptxgenjs/src/gen-media.ts | 236 + packages/pptxgenjs/src/gen-objects.ts | 1269 + packages/pptxgenjs/src/gen-tables.ts | 749 + packages/pptxgenjs/src/gen-utils.ts | 275 + packages/pptxgenjs/src/gen-xml.ts | 1952 ++ packages/pptxgenjs/src/pptxgen.ts | 791 + packages/pptxgenjs/src/slide.ts | 257 + packages/pptxgenjs/tsconfig.json | 24 + packages/pptxgenjs/types/index.d.ts | 2712 +++ pnpm-lock.yaml | 15300 ++++++++++++ postcss.config.mjs | 7 + public/avatars/assist-2.png | Bin 0 -> 9665 bytes public/avatars/assist.png | Bin 0 -> 8548 bytes public/avatars/assistant.svg | 1 + public/avatars/builder.svg | 1 + public/avatars/clown-2.png | Bin 0 -> 13871 bytes public/avatars/clown.png | Bin 0 -> 9506 bytes public/avatars/clown.svg | 1 + public/avatars/coder.svg | 1 + public/avatars/creative.svg | 1 + public/avatars/curious-2.png | Bin 0 -> 16718 bytes public/avatars/curious.png | Bin 0 -> 8830 bytes public/avatars/curious.svg | 1 + public/avatars/dreamer.svg | 1 + public/avatars/explorer.svg | 1 + public/avatars/learner.svg | 1 + public/avatars/note-taker-2.png | Bin 0 -> 10561 bytes public/avatars/note-taker.png | Bin 0 -> 9598 bytes public/avatars/notes.svg | 1 + public/avatars/reader.svg | 1 + public/avatars/scholar.svg | 1 + public/avatars/student1.svg | 1 + public/avatars/student2.svg | 1 + public/avatars/student3.svg | 1 + public/avatars/teacher-2.png | Bin 0 -> 10026 bytes public/avatars/teacher.png | Bin 0 -> 1045093 bytes public/avatars/teacher.svg | 1 + public/avatars/thinker-2.png | Bin 0 -> 13116 bytes public/avatars/thinker.png | Bin 0 -> 8913 bytes public/avatars/thinker.svg | 1 + public/avatars/user.png | Bin 0 -> 6976 bytes public/avatars/user.svg | 1 + public/logo-horizontal.png | Bin 0 -> 147781 bytes public/logos/azure.svg | 1 + public/logos/bailian.svg | 1 + public/logos/browser.svg | 2 + public/logos/claude.svg | 1 + public/logos/deepseek.svg | 1 + public/logos/doubao.svg | 1 + public/logos/gemini.svg | 1 + public/logos/glm.svg | 21 + public/logos/kimi.png | Bin 0 -> 19541 bytes public/logos/kling.svg | 1 + public/logos/mineru.png | Bin 0 -> 8722 bytes public/logos/minimax.svg | 1 + public/logos/openai.svg | 21 + public/logos/qwen.svg | 15 + public/logos/siliconflow.svg | 1 + public/logos/unpdf.svg | 17 + tsconfig.json | 34 + 510 files changed, 125393 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 app/api/azure-voices/route.ts create mode 100644 app/api/chat/route.ts create mode 100644 app/api/generate-classroom/route.ts create mode 100644 app/api/generate/agent-profiles/route.ts create mode 100644 app/api/generate/image/route.ts create mode 100644 app/api/generate/route.ts create mode 100644 app/api/generate/scene-actions/route.ts create mode 100644 app/api/generate/scene-content/route.ts create mode 100644 app/api/generate/scene-outlines-stream/route.ts create mode 100644 app/api/generate/scene-outlines/route.ts create mode 100644 app/api/generate/scene/route.ts create mode 100644 app/api/generate/tts/route.ts create mode 100644 app/api/generate/video/route.ts create mode 100644 app/api/parse-pdf/route.ts create mode 100644 app/api/pbl/chat/route.ts create mode 100644 app/api/proxy-media/route.ts create mode 100644 app/api/quiz-grade/route.ts create mode 100644 app/api/server-providers/route.ts create mode 100644 app/api/transcription/route.ts create mode 100644 app/api/verify-image-provider/route.ts create mode 100644 app/api/verify-model/route.ts create mode 100644 app/api/verify-video-provider/route.ts create mode 100644 app/api/web-search/route.ts create mode 100644 app/apple-icon.png create mode 100644 app/classroom/[id]/page.tsx create mode 100644 app/favicon.ico create mode 100644 app/generation-preview/layout.tsx create mode 100644 app/generation-preview/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components.json create mode 100644 components/agent-avatar.tsx create mode 100644 components/agent-bar.tsx create mode 100644 components/agent-config-panel.tsx create mode 100644 components/agent-reveal-modal.tsx create mode 100644 components/ai-elements/artifact.tsx create mode 100644 components/ai-elements/canvas.tsx create mode 100644 components/ai-elements/chain-of-thought.tsx create mode 100644 components/ai-elements/checkpoint.tsx create mode 100644 components/ai-elements/code-block.tsx create mode 100644 components/ai-elements/confirmation.tsx create mode 100644 components/ai-elements/connection.tsx create mode 100644 components/ai-elements/context.tsx create mode 100644 components/ai-elements/controls.tsx create mode 100644 components/ai-elements/conversation.tsx create mode 100644 components/ai-elements/edge.tsx create mode 100644 components/ai-elements/image.tsx create mode 100644 components/ai-elements/inline-citation.tsx create mode 100644 components/ai-elements/loader.tsx create mode 100644 components/ai-elements/message.tsx create mode 100644 components/ai-elements/model-selector.tsx create mode 100644 components/ai-elements/node.tsx create mode 100644 components/ai-elements/open-in-chat.tsx create mode 100644 components/ai-elements/panel.tsx create mode 100644 components/ai-elements/plan.tsx create mode 100644 components/ai-elements/prompt-input.tsx create mode 100644 components/ai-elements/queue.tsx create mode 100644 components/ai-elements/reasoning.tsx create mode 100644 components/ai-elements/shimmer.tsx create mode 100644 components/ai-elements/sources.tsx create mode 100644 components/ai-elements/suggestion.tsx create mode 100644 components/ai-elements/task.tsx create mode 100644 components/ai-elements/tool.tsx create mode 100644 components/ai-elements/toolbar.tsx create mode 100644 components/ai-elements/web-preview.tsx create mode 100644 components/canvas-area.tsx create mode 100644 components/canvas-toolbar.tsx create mode 100644 components/chat/chat-area.tsx create mode 100644 components/chat/chat-session.tsx create mode 100644 components/chat/inline-action-tag.tsx create mode 100644 components/chat/lecture-notes-view.tsx create mode 100644 components/chat/process-sse-stream.ts create mode 100644 components/chat/session-list.tsx create mode 100644 components/chat/use-chat-sessions.ts create mode 100644 components/generation-toolbar.tsx create mode 100644 components/generation/generating-progress.tsx create mode 100644 components/generation/outlines-editor.tsx create mode 100644 components/header.tsx create mode 100644 components/media-popover.tsx create mode 100644 components/proactive-card.tsx create mode 100644 components/roundtable/index.tsx create mode 100644 components/scene-renderers/interactive-renderer.tsx create mode 100644 components/scene-renderers/pbl-renderer.tsx create mode 100644 components/scene-renderers/pbl/chat-panel.tsx create mode 100644 components/scene-renderers/pbl/guide.tsx create mode 100644 components/scene-renderers/pbl/issueboard-panel.tsx create mode 100644 components/scene-renderers/pbl/role-selection.tsx create mode 100644 components/scene-renderers/pbl/use-pbl-chat.ts create mode 100644 components/scene-renderers/pbl/workspace.tsx create mode 100644 components/scene-renderers/quiz-renderer.tsx create mode 100644 components/scene-renderers/quiz-view.tsx create mode 100644 components/server-providers-init.tsx create mode 100644 components/settings/add-provider-dialog.tsx create mode 100644 components/settings/agent-settings.tsx create mode 100644 components/settings/asr-settings.tsx create mode 100644 components/settings/audio-settings.tsx create mode 100644 components/settings/general-settings.tsx create mode 100644 components/settings/image-settings.tsx create mode 100644 components/settings/index.tsx create mode 100644 components/settings/model-edit-dialog.tsx create mode 100644 components/settings/model-selector.tsx create mode 100644 components/settings/pdf-settings.tsx create mode 100644 components/settings/provider-config-panel.tsx create mode 100644 components/settings/provider-list.tsx create mode 100644 components/settings/tts-settings.tsx create mode 100644 components/settings/utils.ts create mode 100644 components/settings/video-settings.tsx create mode 100644 components/settings/web-search-settings.tsx create mode 100644 components/slide-renderer/Editor/Canvas/AlignmentLine.tsx create mode 100644 components/slide-renderer/Editor/Canvas/EditableElement.tsx create mode 100644 components/slide-renderer/Editor/Canvas/ElementCreateSelection.tsx create mode 100644 components/slide-renderer/Editor/Canvas/GridLines.tsx create mode 100644 components/slide-renderer/Editor/Canvas/MouseSelection.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/BorderLine.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/CommonElementOperate.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/ImageElementOperate.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/LineElementOperate.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/MultiSelectOperate.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/ResizeHandler.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/RotateHandler.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/ShapeElementOperate.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/TableElementOperate.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/TextElementOperate.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Operate/index.tsx create mode 100644 components/slide-renderer/Editor/Canvas/Ruler.tsx create mode 100644 components/slide-renderer/Editor/Canvas/ShapeCreateCanvas.tsx create mode 100644 components/slide-renderer/Editor/Canvas/ViewportBackground.tsx create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useCommonOperate.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useDragElement.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useDragLineElement.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useDrop.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useMouseSelection.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useMoveShapeKeypoint.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useRotateElement.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useScaleElement.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useSelectElement.ts create mode 100644 components/slide-renderer/Editor/Canvas/hooks/useViewportSize.ts create mode 100644 components/slide-renderer/Editor/Canvas/index.tsx create mode 100644 components/slide-renderer/Editor/CanvasTool/index.tsx create mode 100644 components/slide-renderer/Editor/HighlightOverlay.tsx create mode 100644 components/slide-renderer/Editor/LaserOverlay.tsx create mode 100644 components/slide-renderer/Editor/ScreenCanvas.tsx create mode 100644 components/slide-renderer/Editor/ScreenElement.tsx create mode 100644 components/slide-renderer/Editor/SpotlightOverlay.tsx create mode 100644 components/slide-renderer/Editor/ZoomWrapper.tsx create mode 100644 components/slide-renderer/Editor/index.tsx create mode 100644 components/slide-renderer/components/ThumbnailSlide/ThumbnailElement.tsx create mode 100644 components/slide-renderer/components/ThumbnailSlide/index.tsx create mode 100644 components/slide-renderer/components/element/ChartElement/BaseChartElement.tsx create mode 100644 components/slide-renderer/components/element/ChartElement/Chart.tsx create mode 100644 components/slide-renderer/components/element/ChartElement/chartOption.ts create mode 100644 components/slide-renderer/components/element/ChartElement/index.tsx create mode 100644 components/slide-renderer/components/element/ElementOutline.tsx create mode 100644 components/slide-renderer/components/element/ImageElement/BaseImageElement.tsx create mode 100644 components/slide-renderer/components/element/ImageElement/ImageClipHandler.tsx create mode 100644 components/slide-renderer/components/element/ImageElement/ImageOutline/image-ellipse-outline.tsx create mode 100644 components/slide-renderer/components/element/ImageElement/ImageOutline/image-polygon-outline.tsx create mode 100644 components/slide-renderer/components/element/ImageElement/ImageOutline/image-rect-outline.tsx create mode 100644 components/slide-renderer/components/element/ImageElement/ImageOutline/index.tsx create mode 100644 components/slide-renderer/components/element/ImageElement/index.tsx create mode 100644 components/slide-renderer/components/element/ImageElement/useClipImage.ts create mode 100644 components/slide-renderer/components/element/ImageElement/useFilter.ts create mode 100644 components/slide-renderer/components/element/LatexElement/BaseLatexElement.tsx create mode 100644 components/slide-renderer/components/element/LatexElement/index.tsx create mode 100644 components/slide-renderer/components/element/LineElement/BaseLineElement.tsx create mode 100644 components/slide-renderer/components/element/LineElement/LinePointMarker.tsx create mode 100644 components/slide-renderer/components/element/LineElement/index.tsx create mode 100644 components/slide-renderer/components/element/ProsemirrorEditor.tsx create mode 100644 components/slide-renderer/components/element/ShapeElement/BaseShapeElement.tsx create mode 100644 components/slide-renderer/components/element/ShapeElement/GradientDefs.tsx create mode 100644 components/slide-renderer/components/element/ShapeElement/PatternDefs.tsx create mode 100644 components/slide-renderer/components/element/ShapeElement/index.tsx create mode 100644 components/slide-renderer/components/element/TableElement/BaseTableElement.tsx create mode 100644 components/slide-renderer/components/element/TableElement/StaticTable.tsx create mode 100644 components/slide-renderer/components/element/TableElement/index.tsx create mode 100644 components/slide-renderer/components/element/TableElement/tableUtils.ts create mode 100644 components/slide-renderer/components/element/TextElement/BaseTextElement.tsx create mode 100644 components/slide-renderer/components/element/TextElement/index.tsx create mode 100644 components/slide-renderer/components/element/VideoElement/BaseVideoElement.tsx create mode 100644 components/slide-renderer/components/element/VideoElement/index.tsx create mode 100644 components/slide-renderer/components/element/hooks/useElementFill.ts create mode 100644 components/slide-renderer/components/element/hooks/useElementFlip.ts create mode 100644 components/slide-renderer/components/element/hooks/useElementOutline.ts create mode 100644 components/slide-renderer/components/element/hooks/useElementShadow.ts create mode 100644 components/speech-button.tsx create mode 100644 components/stage.tsx create mode 100644 components/stage/scene-renderer.tsx create mode 100644 components/stage/scene-sidebar.tsx create mode 100644 components/tts-config-popover.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/combobox.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/user-profile.tsx create mode 100644 components/whiteboard/index.tsx create mode 100644 components/whiteboard/whiteboard-canvas.tsx create mode 100644 configs/animation.ts create mode 100644 configs/chart.ts create mode 100644 configs/element.ts create mode 100644 configs/font.ts create mode 100644 configs/hotkey.ts create mode 100644 configs/image-clip.ts create mode 100644 configs/latex.ts create mode 100644 configs/lines.ts create mode 100644 configs/mime.ts create mode 100644 configs/shapes.ts create mode 100644 configs/storage.ts create mode 100644 configs/symbol.ts create mode 100644 configs/theme.ts create mode 100644 eslint.config.mjs create mode 100644 lib/action/engine.ts create mode 100644 lib/ai/llm.ts create mode 100644 lib/ai/providers.ts create mode 100644 lib/ai/thinking-context.ts create mode 100644 lib/api/stage-api.ts create mode 100644 lib/audio/asr-providers.ts create mode 100644 lib/audio/azure.json create mode 100644 lib/audio/constants.ts create mode 100644 lib/audio/qwen.html create mode 100644 lib/audio/tts-providers.ts create mode 100644 lib/audio/types.ts create mode 100644 lib/buffer/stream-buffer.ts create mode 100644 lib/chat/action-translations.ts create mode 100644 lib/constants/generation.ts create mode 100644 lib/export/html-parser/format.ts create mode 100644 lib/export/html-parser/index.ts create mode 100644 lib/export/html-parser/lexer.ts create mode 100644 lib/export/html-parser/parser.ts create mode 100644 lib/export/html-parser/stringify.ts create mode 100644 lib/export/html-parser/tags.ts create mode 100644 lib/export/html-parser/types.ts create mode 100644 lib/export/latex-to-omml.ts create mode 100644 lib/export/svg-arc-to-cubic-bezier.d.ts create mode 100644 lib/export/svg-path-parser.ts create mode 100644 lib/export/svg2base64.ts create mode 100644 lib/export/use-export-pptx.ts create mode 100644 lib/generation/action-parser.ts create mode 100644 lib/generation/generation-pipeline.ts create mode 100644 lib/generation/interactive-post-processor.ts create mode 100644 lib/generation/prompts/index.ts create mode 100644 lib/generation/prompts/loader.ts create mode 100644 lib/generation/prompts/snippets/action-types.md create mode 100644 lib/generation/prompts/snippets/element-types.md create mode 100644 lib/generation/prompts/snippets/json-output-rules.md create mode 100644 lib/generation/prompts/templates/interactive-actions/system.md create mode 100644 lib/generation/prompts/templates/interactive-actions/user.md create mode 100644 lib/generation/prompts/templates/interactive-html/system.md create mode 100644 lib/generation/prompts/templates/interactive-html/user.md create mode 100644 lib/generation/prompts/templates/interactive-scientific-model/system.md create mode 100644 lib/generation/prompts/templates/interactive-scientific-model/user.md create mode 100644 lib/generation/prompts/templates/pbl-actions/system.md create mode 100644 lib/generation/prompts/templates/pbl-actions/user.md create mode 100644 lib/generation/prompts/templates/quiz-actions/system.md create mode 100644 lib/generation/prompts/templates/quiz-actions/user.md create mode 100644 lib/generation/prompts/templates/quiz-content/system.md create mode 100644 lib/generation/prompts/templates/quiz-content/user.md create mode 100644 lib/generation/prompts/templates/requirements-to-outlines/system.md create mode 100644 lib/generation/prompts/templates/requirements-to-outlines/user.md create mode 100644 lib/generation/prompts/templates/slide-actions/system.md create mode 100644 lib/generation/prompts/templates/slide-actions/user.md create mode 100644 lib/generation/prompts/templates/slide-content/system.md create mode 100644 lib/generation/prompts/templates/slide-content/user.md create mode 100644 lib/generation/prompts/types.ts create mode 100644 lib/hooks/use-audio-recorder.ts create mode 100644 lib/hooks/use-browser-asr.ts create mode 100644 lib/hooks/use-browser-tts.ts create mode 100644 lib/hooks/use-canvas-operations.ts create mode 100644 lib/hooks/use-draft-cache.ts create mode 100644 lib/hooks/use-history-snapshot.ts create mode 100644 lib/hooks/use-order-element.ts create mode 100644 lib/hooks/use-scene-generator.ts create mode 100644 lib/hooks/use-slide-background-style.ts create mode 100644 lib/hooks/use-streaming-text.ts create mode 100644 lib/i18n/chat.ts create mode 100644 lib/i18n/common.ts create mode 100644 lib/i18n/generation.ts create mode 100644 lib/i18n/index.ts create mode 100644 lib/i18n/settings.ts create mode 100644 lib/i18n/stage.ts create mode 100644 lib/i18n/types.ts create mode 100644 lib/logger.ts create mode 100644 lib/media/adapters/kling-adapter.ts create mode 100644 lib/media/adapters/nano-banana-adapter.ts create mode 100644 lib/media/adapters/qwen-image-adapter.ts create mode 100644 lib/media/adapters/seedance-adapter.ts create mode 100644 lib/media/adapters/seedream-adapter.ts create mode 100644 lib/media/adapters/veo-adapter.ts create mode 100644 lib/media/image-providers.ts create mode 100644 lib/media/media-orchestrator.ts create mode 100644 lib/media/types.ts create mode 100644 lib/media/video-providers.ts create mode 100644 lib/orchestration/ai-sdk-adapter.ts create mode 100644 lib/orchestration/director-graph.ts create mode 100644 lib/orchestration/director-prompt.ts create mode 100644 lib/orchestration/prompt-builder.ts create mode 100644 lib/orchestration/registry/store.ts create mode 100644 lib/orchestration/registry/types.ts create mode 100644 lib/orchestration/stateless-generate.ts create mode 100644 lib/orchestration/tool-schemas.ts create mode 100644 lib/pbl/generate-pbl.ts create mode 100644 lib/pbl/mcp/agent-mcp.ts create mode 100644 lib/pbl/mcp/agent-templates.ts create mode 100644 lib/pbl/mcp/issueboard-mcp.ts create mode 100644 lib/pbl/mcp/mode-mcp.ts create mode 100644 lib/pbl/mcp/project-mcp.ts create mode 100644 lib/pbl/pbl-system-prompt.ts create mode 100644 lib/pbl/types.ts create mode 100644 lib/pdf/README.md create mode 100644 lib/pdf/constants.ts create mode 100644 lib/pdf/pdf-providers.ts create mode 100644 lib/pdf/types.ts create mode 100644 lib/playback/derived-state.ts create mode 100644 lib/playback/engine.ts create mode 100644 lib/playback/index.ts create mode 100644 lib/playback/types.ts create mode 100644 lib/prosemirror/commands/replaceText.ts create mode 100644 lib/prosemirror/commands/setListStyle.ts create mode 100644 lib/prosemirror/commands/setTextAlign.ts create mode 100644 lib/prosemirror/commands/setTextIndent.ts create mode 100644 lib/prosemirror/commands/toggleList.ts create mode 100644 lib/prosemirror/index.ts create mode 100644 lib/prosemirror/plugins/index.ts create mode 100644 lib/prosemirror/plugins/inputrules.ts create mode 100644 lib/prosemirror/plugins/keymap.ts create mode 100644 lib/prosemirror/plugins/placeholder.ts create mode 100644 lib/prosemirror/schema/index.ts create mode 100644 lib/prosemirror/schema/marks.ts create mode 100644 lib/prosemirror/schema/nodes.ts create mode 100644 lib/prosemirror/utils.ts create mode 100644 lib/server/api-response.ts create mode 100644 lib/server/provider-config.ts create mode 100644 lib/server/proxy-fetch.ts create mode 100644 lib/server/ssrf-guard.ts create mode 100644 lib/storage/index.ts create mode 100644 lib/storage/providers/noop.ts create mode 100644 lib/storage/types.ts create mode 100644 lib/store/canvas.ts create mode 100644 lib/store/index.ts create mode 100644 lib/store/keyboard.ts create mode 100644 lib/store/media-generation.ts create mode 100644 lib/store/media-stage-context.tsx create mode 100644 lib/store/scene-context.tsx create mode 100644 lib/store/settings.ts create mode 100644 lib/store/snapshot.ts create mode 100644 lib/store/stage.ts create mode 100644 lib/store/user-profile.ts create mode 100644 lib/types/action.ts create mode 100644 lib/types/chat.ts create mode 100644 lib/types/edit.ts create mode 100644 lib/types/export.ts create mode 100644 lib/types/generation.ts create mode 100644 lib/types/pdf.ts create mode 100644 lib/types/provider.ts create mode 100644 lib/types/roundtable.ts create mode 100644 lib/types/settings.ts create mode 100644 lib/types/slides.ts create mode 100644 lib/types/stage.ts create mode 100644 lib/types/toolbar.ts create mode 100644 lib/types/web-search.ts create mode 100644 lib/use-i18n.tsx create mode 100644 lib/use-theme.tsx create mode 100644 lib/utils.ts create mode 100644 lib/utils/audio-player.ts create mode 100644 lib/utils/chat-storage.ts create mode 100644 lib/utils/create-selectors.ts create mode 100644 lib/utils/database.ts create mode 100644 lib/utils/element.ts create mode 100644 lib/utils/emitter.ts create mode 100644 lib/utils/geometry.ts create mode 100644 lib/utils/image-storage.ts create mode 100644 lib/utils/model-config.ts create mode 100644 lib/utils/playback-storage.ts create mode 100644 lib/utils/stage-storage.ts create mode 100644 lib/web-search/constants.ts create mode 100644 lib/web-search/tavily.ts create mode 100644 lib/web-search/types.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 packages/mathml2omml/.gitignore create mode 100644 packages/mathml2omml/LICENSE create mode 100644 packages/mathml2omml/package.json create mode 100644 packages/mathml2omml/rollup.config.js create mode 100644 packages/mathml2omml/src/helpers.js create mode 100644 packages/mathml2omml/src/index.d.ts create mode 100644 packages/mathml2omml/src/index.js create mode 100644 packages/mathml2omml/src/mathml/index.js create mode 100644 packages/mathml2omml/src/mathml/math.js create mode 100644 packages/mathml2omml/src/mathml/menclose.js create mode 100644 packages/mathml2omml/src/mathml/mfrac.js create mode 100644 packages/mathml2omml/src/mathml/mglyph.js create mode 100644 packages/mathml2omml/src/mathml/mmultiscripts.js create mode 100644 packages/mathml2omml/src/mathml/mroot.js create mode 100644 packages/mathml2omml/src/mathml/mrow.js create mode 100644 packages/mathml2omml/src/mathml/mspace.js create mode 100644 packages/mathml2omml/src/mathml/msqrt.js create mode 100644 packages/mathml2omml/src/mathml/mstyle.js create mode 100644 packages/mathml2omml/src/mathml/msub.js create mode 100644 packages/mathml2omml/src/mathml/msubsup.js create mode 100644 packages/mathml2omml/src/mathml/msup.js create mode 100644 packages/mathml2omml/src/mathml/munderover.js create mode 100644 packages/mathml2omml/src/mathml/table.js create mode 100644 packages/mathml2omml/src/mathml/text.js create mode 100644 packages/mathml2omml/src/mathml/text_container.js create mode 100644 packages/mathml2omml/src/mathml/text_style.js create mode 100644 packages/mathml2omml/src/mathml/under_or_over.js create mode 100644 packages/mathml2omml/src/ooml/index.js create mode 100644 packages/mathml2omml/src/ooml/nary.js create mode 100644 packages/mathml2omml/src/ooml/scriptlevel.js create mode 100644 packages/mathml2omml/src/parse-stringify/index.js create mode 100644 packages/mathml2omml/src/parse-stringify/parse-tag.js create mode 100644 packages/mathml2omml/src/parse-stringify/parse.js create mode 100644 packages/mathml2omml/src/parse-stringify/stringify.js create mode 100644 packages/mathml2omml/src/walker.js create mode 100644 packages/pptxgenjs/.gitignore create mode 100644 packages/pptxgenjs/package.json create mode 100644 packages/pptxgenjs/rollup.config.mjs create mode 100644 packages/pptxgenjs/src/core-enums.ts create mode 100644 packages/pptxgenjs/src/core-interfaces.ts create mode 100644 packages/pptxgenjs/src/gen-charts.ts create mode 100644 packages/pptxgenjs/src/gen-media.ts create mode 100644 packages/pptxgenjs/src/gen-objects.ts create mode 100644 packages/pptxgenjs/src/gen-tables.ts create mode 100644 packages/pptxgenjs/src/gen-utils.ts create mode 100644 packages/pptxgenjs/src/gen-xml.ts create mode 100644 packages/pptxgenjs/src/pptxgen.ts create mode 100644 packages/pptxgenjs/src/slide.ts create mode 100644 packages/pptxgenjs/tsconfig.json create mode 100644 packages/pptxgenjs/types/index.d.ts create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100755 public/avatars/assist-2.png create mode 100755 public/avatars/assist.png create mode 100644 public/avatars/assistant.svg create mode 100644 public/avatars/builder.svg create mode 100755 public/avatars/clown-2.png create mode 100755 public/avatars/clown.png create mode 100644 public/avatars/clown.svg create mode 100644 public/avatars/coder.svg create mode 100644 public/avatars/creative.svg create mode 100755 public/avatars/curious-2.png create mode 100755 public/avatars/curious.png create mode 100644 public/avatars/curious.svg create mode 100644 public/avatars/dreamer.svg create mode 100644 public/avatars/explorer.svg create mode 100644 public/avatars/learner.svg create mode 100755 public/avatars/note-taker-2.png create mode 100755 public/avatars/note-taker.png create mode 100644 public/avatars/notes.svg create mode 100644 public/avatars/reader.svg create mode 100644 public/avatars/scholar.svg create mode 100644 public/avatars/student1.svg create mode 100644 public/avatars/student2.svg create mode 100644 public/avatars/student3.svg create mode 100755 public/avatars/teacher-2.png create mode 100755 public/avatars/teacher.png create mode 100644 public/avatars/teacher.svg create mode 100755 public/avatars/thinker-2.png create mode 100755 public/avatars/thinker.png create mode 100644 public/avatars/thinker.svg create mode 100755 public/avatars/user.png create mode 100644 public/avatars/user.svg create mode 100644 public/logo-horizontal.png create mode 100644 public/logos/azure.svg create mode 100644 public/logos/bailian.svg create mode 100644 public/logos/browser.svg create mode 100644 public/logos/claude.svg create mode 100644 public/logos/deepseek.svg create mode 100644 public/logos/doubao.svg create mode 100644 public/logos/gemini.svg create mode 100644 public/logos/glm.svg create mode 100644 public/logos/kimi.png create mode 100644 public/logos/kling.svg create mode 100644 public/logos/mineru.png create mode 100644 public/logos/minimax.svg create mode 100644 public/logos/openai.svg create mode 100644 public/logos/qwen.svg create mode 100644 public/logos/siliconflow.svg create mode 100644 public/logos/unpdf.svg create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..6e93066e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build +/dist + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* +!.env.example + +# server provider config (contains API keys) +server-providers.yml +server-providers-*.yml + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# IDE +.idea +.vscode + +# generated data +/data +/logs diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..7da0064d1 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "proseWrap": "preserve", + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto" +} diff --git a/app/api/azure-voices/route.ts b/app/api/azure-voices/route.ts new file mode 100644 index 000000000..14e92891c --- /dev/null +++ b/app/api/azure-voices/route.ts @@ -0,0 +1,67 @@ +import { NextRequest } from 'next/server'; +import { createLogger } from '@/lib/logger' +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Azure Voices') + + +export const maxDuration = 30; + +/** + * Azure TTS Voice List API + * Fetches available voices from Azure Speech Services + */ +export async function POST(req: NextRequest) { + try { + const { apiKey, baseUrl } = await req.json(); + + if (!apiKey) { + return apiError('MISSING_API_KEY', 400, 'API Key is required'); + } + + if (!baseUrl) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Base URL is required'); + } + + // Validate baseUrl against SSRF + const ssrfError = validateUrlForSSRF(baseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + + // Call Azure voices list endpoint; disable redirect following to prevent SSRF via redirect + const response = await fetch(`${baseUrl}/cognitiveservices/voices/list`, { + method: 'GET', + headers: { + 'Ocp-Apim-Subscription-Key': apiKey, + }, + redirect: 'manual', + }); + + if (response.status >= 300 && response.status < 400) { + return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed'); + } + + if (!response.ok) { + const errorText = await response.text(); + return apiError( + 'UPSTREAM_ERROR', + response.status, + 'Failed to fetch voices from Azure', + errorText || response.statusText, + ); + } + + const voices = await response.json(); + + return apiSuccess({ voices }); + } catch (error) { + log.error('API error:', error); + return apiError( + 'INTERNAL_ERROR', + 500, + 'Failed to fetch voices', + error instanceof Error ? error.message : 'Unknown error', + ); + } +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 000000000..fbd47219c --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,179 @@ +/** + * Stateless Chat API Endpoint + * + * POST /api/chat - Send message, receive SSE stream + * + * This endpoint: + * 1. Receives full state from client (messages + storeState) + * 2. Runs single-pass generation + * 3. Streams events as SSE (text deltas + tool calls) + * + * Fully stateless: interruption is handled by the client aborting + * the fetch request, which triggers req.signal on the server side. + */ + +import { NextRequest } from 'next/server'; +import { statelessGenerate } from '@/lib/orchestration/stateless-generate'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat'; +import type { ThinkingConfig } from '@/lib/types/provider'; +import { apiError } from '@/lib/server/api-response' +import { createLogger } from '@/lib/logger' +const log = createLogger('Chat API') + + +// Allow streaming responses up to 60 seconds +export const maxDuration = 60; + +/** + * POST /api/chat + * Send a message and receive SSE stream of generation events + * + * Request body: StatelessChatRequest + * { + * messages: UIMessage[], + * storeState: { stage, scenes, currentSceneId, mode }, + * config: { agentIds, sessionType? }, + * apiKey: string, + * baseUrl?: string, + * model?: string + * } + * + * Response: SSE stream of StatelessEvent + */ +export async function POST(req: NextRequest) { + const encoder = new TextEncoder(); + + try { + const body: StatelessChatRequest = await req.json(); + + // Validate required fields + if (!body.messages || !Array.isArray(body.messages)) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: messages'); + } + + if (!body.storeState) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: storeState'); + } + + if (!body.config || !body.config.agentIds || body.config.agentIds.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: config.agentIds'); + } + + // Resolve API key: client > server > empty + const modelString = body.model || 'gpt-4o-mini'; + const { providerId, modelId } = parseModelString(modelString); + const effectiveApiKey = resolveApiKey(providerId, body.apiKey); + const effectiveBaseUrl = resolveBaseUrl(providerId, body.baseUrl); + const proxy = resolveProxy(providerId); + + if (!effectiveApiKey) { + return apiError('MISSING_API_KEY', 401, 'API Key is required'); + } + + log.info('Processing request'); + log.info(`Agents: ${body.config.agentIds.join(', ')}, Messages: ${body.messages.length}, Turn: ${body.directorState?.turnCount ?? 0}`); + + // Create LanguageModel via the unified provider system + const { model: languageModel } = getModel({ + providerId, + modelId, + apiKey: effectiveApiKey, + baseUrl: effectiveBaseUrl, + proxy, + }); + + // Use the native request signal for abort propagation + const signal = req.signal; + + // Create SSE stream + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + + // Stream generation in background with heartbeat to prevent connection timeout + const HEARTBEAT_INTERVAL_MS = 15_000; + (async () => { + // Heartbeat: periodically send SSE comments to keep the connection alive. + // Proxies / browsers may close idle SSE connections after 30-120s of silence. + let heartbeatTimer: ReturnType | null = null; + const startHeartbeat = () => { + stopHeartbeat(); + heartbeatTimer = setInterval(() => { + try { + writer.write(encoder.encode(`:heartbeat\n\n`)).catch(() => stopHeartbeat()); + } catch { + stopHeartbeat(); + } + }, HEARTBEAT_INTERVAL_MS); + }; + const stopHeartbeat = () => { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + }; + + try { + startHeartbeat(); + + const generator = statelessGenerate( + { + ...body, + apiKey: effectiveApiKey, + }, + signal, + languageModel, + { enabled: false } satisfies ThinkingConfig, + ); + + for await (const event of generator) { + if (signal.aborted) { + log.info('Request was aborted'); + break; + } + + const data = `data: ${JSON.stringify(event)}\n\n`; + await writer.write(encoder.encode(data)); + } + + stopHeartbeat(); + await writer.close(); + } catch (error) { + stopHeartbeat(); + + // If aborted, just close the writer silently + if (signal.aborted) { + log.info('Request aborted during streaming'); + try { await writer.close(); } catch { /* already closed */ } + return; + } + + log.error('Stream error:', error); + + // Try to send error event + try { + const errorEvent: StatelessEvent = { + type: 'error', + data: { message: error instanceof Error ? error.message : String(error) }, + }; + await writer.write(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)); + await writer.close(); + } catch { + // Writer may already be closed + } + } + })(); + + return new Response(readable, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } catch (error) { + log.error('Error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed to process request'); + } +} diff --git a/app/api/generate-classroom/route.ts b/app/api/generate-classroom/route.ts new file mode 100644 index 000000000..bd62bbe3a --- /dev/null +++ b/app/api/generate-classroom/route.ts @@ -0,0 +1,252 @@ +import { NextRequest } from "next/server"; +import { getModel, parseModelString } from "@/lib/ai/providers"; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from "@/lib/server/provider-config"; +import { callLLM } from "@/lib/ai/llm"; +import { z } from "zod"; +import { nanoid } from "nanoid"; +import type { ParsedPdfContent } from "@/lib/types/pdf"; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response' +const log = createLogger('Classroom') + + +// Schema for the generated classroom structure +const SlideSchema = z.object({ + title: z.string().describe("Slide title"), + content: z.string().describe("Main text content for the slide"), + keyPoints: z.array(z.string()).describe("Key bullet points"), + imageIndex: z + .number() + .optional() + .describe("Index of image to use from PDF (0-based), if applicable"), +}); + +const ClassroomSchema = z.object({ + title: z.string().describe("Course title"), + description: z.string().describe("Course description"), + slides: z.array(SlideSchema).describe("Generated slides"), +}); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + pdfContent, + background, + model, + apiKey, + baseUrl, + providerType, + requiresApiKey, + } = body as { + pdfContent: ParsedPdfContent; + background?: string; + model?: string; + apiKey?: string; + baseUrl?: string; + providerType?: string; + requiresApiKey?: boolean; + }; + + if (!pdfContent || !pdfContent.text) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'No PDF content provided'); + } + + const modelString = model || "gpt-4o-mini"; + const { providerId, modelId } = parseModelString(modelString); + const effectiveApiKey = resolveApiKey(providerId, apiKey); + const effectiveBaseUrl = resolveBaseUrl(providerId, baseUrl); + const proxy = resolveProxy(providerId); + log.info(`Using model: provider=${providerId}, model=${modelId}`); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey: effectiveApiKey || '', + baseUrl: effectiveBaseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + const backgroundContext = background + ? `\n\nStudent background: ${background}\nPlease adapt the content complexity and examples based on this background.` + : ""; + + const imagesContext = + pdfContent.images.length > 0 + ? `\n\nNote: This PDF contains ${pdfContent.images.length} images. You can reference them in slides by including "imageIndex" field (0-based index).` + : ""; + + const systemMessage = + "You are an expert educator. You MUST respond with valid JSON only, no additional text or markdown formatting."; + const userMessage = `Create a presentation from the following PDF content. + +PDF Content: +${pdfContent.text}${backgroundContext}${imagesContext} + +Generate a JSON object with this exact structure: +{ + "title": "Course title", + "description": "Course description", + "slides": [ + { + "title": "Slide title", + "content": "Main text content for the slide", + "keyPoints": ["Point 1", "Point 2", "Point 3"], + "imageIndex": 0 // Optional: index of image to display (0-based), only include if relevant + } + ] +} + +Requirements: +1. Create an engaging title and description +2. Generate 10-15 slides that cover the main topics +3. Each slide should have a title, main content, and 3-5 key points +4. Structure the content logically and pedagogically +5. Use clear explanations with practical examples +6. If images are available, use "imageIndex" to reference them in appropriate slides + +Respond with ONLY the JSON object, no markdown code blocks or additional text.`; + + log.debug('AI Generation', { systemMessage: systemMessage.substring(0, 100), userMessageLength: userMessage.length, background: background || '(none)', imagesAvailable: pdfContent.images.length }) + + const result = await callLLM({ + model: languageModel, + messages: [ + { + role: "system", + content: systemMessage, + }, + { + role: "user", + content: userMessage, + }, + ], + maxOutputTokens: modelInfo?.outputWindow, + }, 'generate-classroom'); + + log.debug('AI Response', { length: result.text.length, preview: result.text.substring(0, 200) }) + + // Parse the JSON response + let outputData: z.infer; + try { + const jsonText = result.text + .replace(/```json\n?/g, "") + .replace(/```\n?/g, "") + .trim(); + outputData = ClassroomSchema.parse(JSON.parse(jsonText)); + + log.debug('Parsed data', { title: outputData.title, slides: outputData.slides.length }) + } catch (parseError) { + log.error('Failed to parse AI response', parseError) + throw new Error("Failed to parse AI-generated content"); + } + + // Transform the result into the Stage format + const stageId = nanoid(10); + const scenes = outputData.slides.map((slide, index) => ({ + id: `scene_${nanoid(10)}`, + type: "slide" as const, + title: slide.title, + transcript: slide.content, + order: index, + content: { + type: "slide" as const, + canvas: { + id: `slide_${nanoid(10)}`, + viewportSize: 1000, + viewportRatio: 0.5625, + theme: { + themeColors: ["#5b9bd5", "#70ad47", "#ffc000", "#ed7d31"], + fontColor: "#333333", + fontName: "Microsoft YaHei", + backgroundColor: "#ffffff", + outline: { + width: 0, + color: "#000000", + style: "solid" as const, + }, + shadow: { + h: 0, + v: 0, + blur: 0, + color: "#000000", + }, + }, + elements: [ + // Title element + { + type: "text" as const, + id: `text_${nanoid(10)}`, + left: 100, + top: 100, + width: 800, + height: 80, + rotate: 0, + content: `

${slide.title}

`, + defaultFontName: "Microsoft YaHei", + defaultColor: "#5b9bd5", + lineHeight: 1.2, + opacity: 1, + }, + // Image element (if imageIndex is provided and valid) + ...(slide.imageIndex !== undefined && + slide.imageIndex >= 0 && + slide.imageIndex < pdfContent.images.length + ? [ + { + type: "image" as const, + id: `image_${nanoid(10)}`, + left: 550, + top: 220, + width: 350, + height: 350, + rotate: 0, + src: pdfContent.images[slide.imageIndex], + fixedRatio: true, + opacity: 1, + }, + ] + : []), + // Content element + { + type: "text" as const, + id: `text_${nanoid(10)}`, + left: 100, + top: 220, + width: slide.imageIndex !== undefined ? 400 : 800, + height: 400, + rotate: 0, + content: `

${slide.content}

    ${slide.keyPoints.map((point) => `
  • ${point}
  • `).join("")}
`, + defaultFontName: "Microsoft YaHei", + defaultColor: "#333333", + lineHeight: 1.8, + opacity: 1, + }, + ], + background: { + type: "solid" as const, + color: "#ffffff", + }, + }, + }, + })); + + const stage = { + id: stageId, + name: outputData.title, + description: outputData.description, + language: "zh-CN", + style: "interactive", + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + log.debug('Final output', { stageId: stage.id, stageName: stage.name, scenesCount: scenes.length }) + + return apiSuccess({ stage, scenes }); + } catch (error) { + log.error('Error generating classroom:', error); + return apiError('INTERNAL_ERROR', 500, 'Failed to generate classroom', error instanceof Error ? error.message : 'Unknown error'); + } +} diff --git a/app/api/generate/agent-profiles/route.ts b/app/api/generate/agent-profiles/route.ts new file mode 100644 index 000000000..2469655a3 --- /dev/null +++ b/app/api/generate/agent-profiles/route.ts @@ -0,0 +1,177 @@ +/** + * Agent Profiles Generation API + * + * Generates agent profiles (teacher, assistant, student) for a course stage + * based on stage info and scene outlines. + */ + +import { NextRequest } from 'next/server'; +import { nanoid } from 'nanoid'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('Agent Profiles API'); + +export const maxDuration = 120; + +const COLOR_PALETTE = [ + '#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#06b6d4', + '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#6366f1', + '#84cc16', '#a855f7', +]; + +interface RequestBody { + stageInfo: { name: string; description?: string }; + sceneOutlines?: { title: string; description?: string }[]; + language: string; + availableAvatars: string[]; +} + +function stripCodeFences(text: string): string { + let cleaned = text.trim(); + // Remove markdown code fences (```json ... ``` or ``` ... ```) + if (cleaned.startsWith('```')) { + cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); + } + return cleaned.trim(); +} + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as RequestBody; + const { stageInfo, sceneOutlines, language, availableAvatars } = body; + + // ── Validate required fields ── + if (!stageInfo?.name) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'stageInfo.name is required'); + } + if (!language) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'language is required'); + } + if (!availableAvatars || availableAvatars.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'availableAvatars is required and must not be empty'); + } + + // ── Model resolution from request headers ── + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // ── Build prompt ── + const sceneSummary = sceneOutlines?.length + ? sceneOutlines + .map((s, i) => `${i + 1}. ${s.title}${s.description ? ` — ${s.description}` : ''}`) + .join('\n') + : null; + + const systemPrompt = `You are an expert instructional designer. Generate agent profiles for a multi-agent classroom simulation. Decide the appropriate number of agents (typically 3-5) based on the course content and complexity. Return ONLY valid JSON, no markdown or explanation.`; + + const userPrompt = `Generate agent profiles for the following course: + +Course name: ${stageInfo.name} +${stageInfo.description ? `Course description: ${stageInfo.description}` : ''} +${sceneSummary ? `\nScene outlines:\n${sceneSummary}\n` : ''} +Requirements: +- Decide the appropriate number of agents based on the course content (typically 3-5) +- Exactly 1 agent must have role "teacher", the rest can be "assistant" or "student" +- Priority values: teacher=10 (highest), assistant=7, student=4-6 +- Each agent needs: name, role, persona (2-3 sentences describing personality and teaching/learning style) +- Names and personas must be in language: ${language} +- Each agent must be assigned one avatar from this list: ${JSON.stringify(availableAvatars)} + - Try to use different avatars for each agent +- Each agent must be assigned one color from this list: ${JSON.stringify(COLOR_PALETTE)} + - Each agent must have a different color + +Return a JSON object with this exact structure: +{ + "agents": [ + { + "name": "string", + "role": "teacher" | "assistant" | "student", + "persona": "string (2-3 sentences)", + "avatar": "string (from available list)", + "color": "string (hex color from palette)", + "priority": number (10 for teacher, 7 for assistant, 4-6 for student) + } + ] +}`; + + log.info(`Generating agent profiles for "${stageInfo.name}" [model=${modelString}]`); + + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + }, + 'agent-profiles', + ); + + // ── Parse LLM response ── + const rawText = stripCodeFences(result.text); + let parsed: { agents: Array<{ + name: string; + role: string; + persona: string; + avatar: string; + color: string; + priority: number; + }> }; + + try { + parsed = JSON.parse(rawText); + } catch { + log.error('Failed to parse LLM response as JSON:', rawText.substring(0, 500)); + return apiError('PARSE_FAILED', 500, 'Failed to parse agent profiles from LLM response'); + } + + // ── Validate parsed structure ── + if (!parsed.agents || !Array.isArray(parsed.agents) || parsed.agents.length < 2) { + log.error(`Expected at least 2 agents, got ${parsed.agents?.length ?? 0}`); + return apiError('GENERATION_FAILED', 500, `Expected at least 2 agents but LLM returned ${parsed.agents?.length ?? 0}`); + } + + const teacherCount = parsed.agents.filter((a) => a.role === 'teacher').length; + if (teacherCount !== 1) { + log.error(`Expected exactly 1 teacher, got ${teacherCount}`); + return apiError('GENERATION_FAILED', 500, `Expected exactly 1 teacher but LLM returned ${teacherCount}`); + } + + // ── Build output with IDs ── + const agents = parsed.agents.map((agent, index) => ({ + id: `gen-${nanoid(8)}`, + name: agent.name, + role: agent.role, + persona: agent.persona, + avatar: agent.avatar || availableAvatars[index % availableAvatars.length], + color: agent.color || COLOR_PALETTE[index % COLOR_PALETTE.length], + priority: agent.priority ?? (agent.role === 'teacher' ? 10 : agent.role === 'assistant' ? 7 : 5), + })); + + log.info(`Successfully generated ${agents.length} agent profiles for "${stageInfo.name}"`); + + return apiSuccess({ agents }); + } catch (error) { + log.error('Agent profiles generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/image/route.ts b/app/api/generate/image/route.ts new file mode 100644 index 000000000..da105f9d7 --- /dev/null +++ b/app/api/generate/image/route.ts @@ -0,0 +1,77 @@ +/** + * Image Generation API + * + * Generates an image from a text prompt using the specified provider. + * Called by the client during media generation after slides are produced. + * + * POST /api/generate/image + * + * Headers: + * x-image-provider: ImageProviderId (default: 'seedream') + * x-api-key: string (optional, server fallback) + * x-base-url: string (optional, server fallback) + * + * Body: { prompt, negativePrompt?, width?, height?, aspectRatio?, style? } + * Response: { success: boolean, result?: ImageGenerationResult, error?: string } + */ + +import { NextRequest } from 'next/server'; +import { generateImage, aspectRatioToDimensions } from '@/lib/media/image-providers'; +import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-config'; +import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('ImageGeneration API'); + +export const maxDuration = 60; + +export async function POST(request: NextRequest) { + try { + const body = await request.json() as ImageGenerationOptions; + + if (!body.prompt) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt'); + } + + const providerId = (request.headers.get('x-image-provider') || 'seedream') as ImageProviderId; + const clientApiKey = request.headers.get('x-api-key') || undefined; + const clientBaseUrl = request.headers.get('x-base-url') || undefined; + const clientModel = request.headers.get('x-image-model') || undefined; + + const apiKey = resolveImageApiKey(providerId, clientApiKey); + if (!apiKey) { + return apiError('MISSING_API_KEY', 401, `No API key configured for image provider: ${providerId}`); + } + + const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl); + + // Resolve dimensions from aspect ratio if not explicitly set + if (!body.width && !body.height && body.aspectRatio) { + const dims = aspectRatioToDimensions(body.aspectRatio); + body.width = dims.width; + body.height = dims.height; + } + + log.info( + `Generating image: provider=${providerId}, model=${clientModel || 'default'}, ` + + `prompt="${body.prompt.slice(0, 80)}...", size=${body.width ?? 'auto'}x${body.height ?? 'auto'}`, + ); + + const result = await generateImage( + { providerId, apiKey, baseUrl, model: clientModel }, + body, + ); + + return apiSuccess({ result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Detect content safety filter rejections (e.g. Seedream OutputImageSensitiveContentDetected) + if (message.includes('SensitiveContent') || message.includes('sensitive information')) { + log.warn(`Image blocked by content safety filter: ${message}`); + return apiError('CONTENT_SENSITIVE', 400, message); + } + log.error('Image generation error:', error); + return apiError('INTERNAL_ERROR', 500, message); + } +} diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 000000000..163d2165c --- /dev/null +++ b/app/api/generate/route.ts @@ -0,0 +1,120 @@ +/** + * Generation API Route + * + * Triggers the three-stage generation pipeline + */ + +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { + runGenerationPipeline, + createGenerationSession, + type GenerationCallbacks, +} from '@/lib/generation/generation-pipeline'; +import type { UserRequirements } from '@/lib/types/generation'; +import type { Stage, Scene } from '@/lib/types/stage'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response' +const log = createLogger('Generate') + + +// Create a mock store adapter for server-side generation +function createMockStore(initialStage: Stage, initialScenes: Scene[]) { + let state: { + stage: Stage | null; + scenes: Scene[]; + currentSceneId: string | null; + mode: 'autonomous' | 'playback'; + } = { + stage: initialStage, + scenes: initialScenes, + currentSceneId: initialScenes[0]?.id || null, + mode: 'autonomous', + }; + + return { + getState: () => state, + setState: (newState: Partial) => { + state = { ...state, ...newState }; + }, + subscribe: () => () => {}, + }; +} + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { requirements, stage, scenes } = body as { + requirements: UserRequirements; + stage: Stage; + scenes: Scene[]; + }; + + // Get API configuration from request headers + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + // Parse model string and get configured model instance + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // AI call function for the pipeline + const aiCall = async (systemPrompt: string, userPrompt: string): Promise => { + const result = await callLLM({ + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }, 'generate-pipeline'); + return result.text; + }; + + // Create mock store + const mockStore = createMockStore(stage, scenes); + + // Create session + const session = createGenerationSession(requirements); + + // Track progress + const progressLog: string[] = []; + const callbacks: GenerationCallbacks = { + onProgress: (progress) => { + progressLog.push(`[Stage ${progress.currentStage}] ${progress.statusMessage} (${progress.overallProgress}%)`); + }, + onStageComplete: (stageNum, _result) => { + progressLog.push(`Stage ${stageNum} completed`); + }, + onError: (error) => { + progressLog.push(`Error: ${error}`); + }, + }; + + // Run pipeline + const result = await runGenerationPipeline(session, mockStore, aiCall, callbacks); + + if (!result.success) { + return apiError('GENERATION_FAILED', 500, result.error || 'Generation failed', JSON.stringify(progressLog)); + } + + // Return generated scenes + return apiSuccess({ data: { session: result.data, scenes: mockStore.getState().scenes, progressLog } }); + } catch (error) { + log.error('Generation error:', error); + return apiError('INTERNAL_ERROR', 500, String(error)); + } +} diff --git a/app/api/generate/scene-actions/route.ts b/app/api/generate/scene-actions/route.ts new file mode 100644 index 000000000..08a412aca --- /dev/null +++ b/app/api/generate/scene-actions/route.ts @@ -0,0 +1,173 @@ +/** + * Scene Actions Generation API + * + * Generates actions for a scene given its outline and content, + * then assembles the complete Scene object. + * This is the second half of the two-step scene generation pipeline. + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { + generateSceneActions, + buildCompleteScene, + buildVisionUserContent, + type SceneGenerationContext, + type AgentInfo, +} from '@/lib/generation/generation-pipeline'; +import type { SceneOutline } from '@/lib/types/generation'; +import type { + GeneratedSlideContent, + GeneratedQuizContent, + GeneratedInteractiveContent, + GeneratedPBLContent, +} from '@/lib/types/generation'; +import type { SpeechAction } from '@/lib/types/action'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('Scene Actions API'); + +export const maxDuration = 60; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + outline, + allOutlines, + content, + stageId, + agents, + previousSpeeches: incomingPreviousSpeeches, + userProfile, + } = body as { + outline: SceneOutline; + allOutlines: SceneOutline[]; + content: GeneratedSlideContent | GeneratedQuizContent | GeneratedInteractiveContent | GeneratedPBLContent; + stageId: string; + agents?: AgentInfo[]; + previousSpeeches?: string[]; + userProfile?: string; + }; + + // Validate required fields + if (!outline) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required'); + } + if (!allOutlines || allOutlines.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'allOutlines is required and must not be empty'); + } + if (!content) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'content is required'); + } + if (!stageId) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required'); + } + + // ── Model resolution from request headers ── + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // Detect vision capability + const hasVision = !!modelInfo?.capabilities?.vision; + + // AI call function (actions typically don't use vision, but kept for consistency) + const aiCall = async ( + systemPrompt: string, + userPrompt: string, + images?: Array<{ id: string; src: string }>, + ): Promise => { + if (images?.length && hasVision) { + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + messages: [ + { role: 'user' as const, content: buildVisionUserContent(userPrompt, images) }, + ], + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene-actions', + ); + return result.text; + } + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene-actions', + ); + return result.text; + }; + + // ── Build cross-scene context ── + const allTitles = allOutlines.map((o) => o.title); + const pageIndex = allOutlines.findIndex((o) => o.id === outline.id); + const ctx: SceneGenerationContext = { + pageIndex: (pageIndex >= 0 ? pageIndex : 0) + 1, + totalPages: allOutlines.length, + allTitles, + previousSpeeches: incomingPreviousSpeeches ?? [], + }; + + // ── Generate actions ── + log.info(`Generating actions: "${outline.title}" (${outline.type}) [model=${modelString}]`); + + const actions = await generateSceneActions( + outline, + content, + aiCall, + ctx, + agents, + userProfile, + ); + + log.info(`Generated ${actions.length} actions for: "${outline.title}"`); + + // ── Build complete scene ── + const scene = buildCompleteScene(outline, content, actions, stageId); + + if (!scene) { + log.error(`Failed to build scene: "${outline.title}"`); + + return apiError('GENERATION_FAILED', 500, `Failed to build scene: ${outline.title}`); + } + + // ── Extract speeches for cross-scene coherence ── + const outputPreviousSpeeches = (scene.actions || []) + .filter((a): a is SpeechAction => a.type === 'speech') + .map((a) => a.text); + + log.info( + `Scene assembled successfully: "${outline.title}" — ${scene.actions?.length ?? 0} actions`, + ); + + return apiSuccess({ scene, previousSpeeches: outputPreviousSpeeches }); + } catch (error) { + log.error('Scene actions generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/scene-content/route.ts b/app/api/generate/scene-content/route.ts new file mode 100644 index 000000000..2eebdbb7e --- /dev/null +++ b/app/api/generate/scene-content/route.ts @@ -0,0 +1,173 @@ +/** + * Scene Content Generation API + * + * Generates scene content (slides/quiz/interactive/pbl) from an outline. + * This is the first half of the two-step scene generation pipeline. + * Does NOT generate actions — use /api/generate/scene-actions for that. + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { + applyOutlineFallbacks, + generateSceneContent, + buildVisionUserContent, +} from '@/lib/generation/generation-pipeline'; +import type { AgentInfo } from '@/lib/generation/generation-pipeline'; +import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('Scene Content API'); + +export const maxDuration = 300; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + outline: rawOutline, + allOutlines, + pdfImages, + imageMapping, + stageInfo, + stageId, + agents, + } = body as { + outline: SceneOutline; + allOutlines: SceneOutline[]; + pdfImages?: PdfImage[]; + imageMapping?: ImageMapping; + stageInfo: { + name: string; + description?: string; + language?: string; + style?: string; + }; + stageId: string; + agents?: AgentInfo[]; + }; + + // Validate required fields + if (!rawOutline) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required'); + } + if (!allOutlines || allOutlines.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'allOutlines is required and must not be empty'); + } + if (!stageId) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required'); + } + + // Ensure outline has language from stageInfo (fallback for older outlines) + const outline: SceneOutline = { + ...rawOutline, + language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN', + }; + + // ── Model resolution from request headers ── + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // Detect vision capability + const hasVision = !!modelInfo?.capabilities?.vision; + + // Vision-aware AI call function + const aiCall = async ( + systemPrompt: string, + userPrompt: string, + images?: Array<{ id: string; src: string }>, + ): Promise => { + if (images?.length && hasVision) { + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + messages: [ + { role: 'user' as const, content: buildVisionUserContent(userPrompt, images) }, + ], + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene-content', + ); + return result.text; + } + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene-content', + ); + return result.text; + }; + + // ── Apply fallbacks ── + const effectiveOutline = applyOutlineFallbacks(outline, !!languageModel); + + // ── Filter images assigned to this outline ── + let assignedImages: PdfImage[] | undefined; + if ( + pdfImages && + pdfImages.length > 0 && + effectiveOutline.suggestedImageIds && + effectiveOutline.suggestedImageIds.length > 0 + ) { + const suggestedIds = new Set(effectiveOutline.suggestedImageIds); + assignedImages = pdfImages.filter((img) => suggestedIds.has(img.id)); + } + + // ── Media generation is handled client-side in parallel (media-orchestrator.ts) ── + // The content generator receives placeholder IDs (gen_img_1, gen_vid_1) as-is. + // resolveImageIds() in generation-pipeline.ts will keep these placeholders in elements. + const generatedMediaMapping: ImageMapping = {}; + + // ── Generate content ── + log.info(`Generating content: "${effectiveOutline.title}" (${effectiveOutline.type}) [model=${modelString}]`); + + const content = await generateSceneContent( + effectiveOutline, + aiCall, + assignedImages, + imageMapping, + effectiveOutline.type === 'pbl' ? languageModel : undefined, + hasVision, + generatedMediaMapping, + agents, + ); + + if (!content) { + log.error(`Failed to generate content for: "${effectiveOutline.title}"`); + + return apiError('GENERATION_FAILED', 500, `Failed to generate content: ${effectiveOutline.title}`); + } + + log.info(`Content generated successfully: "${effectiveOutline.title}"`); + + return apiSuccess({ content, effectiveOutline }); + } catch (error) { + log.error('Scene content generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/scene-outlines-stream/route.ts b/app/api/generate/scene-outlines-stream/route.ts new file mode 100644 index 000000000..df267362c --- /dev/null +++ b/app/api/generate/scene-outlines-stream/route.ts @@ -0,0 +1,322 @@ +/** + * Scene Outlines Streaming API (SSE) + * + * Streams outline generation via Server-Sent Events. + * Emits individual outline objects as they're parsed from the LLM response, + * so the frontend can display them incrementally. + * + * SSE events: + * { type: 'outline', data: SceneOutline, index: number } + * { type: 'done', outlines: SceneOutline[] } + * { type: 'error', error: string } + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { streamLLM } from '@/lib/ai/llm'; +import { buildPrompt, PROMPT_IDS } from '@/lib/generation/prompts'; +import { formatImageDescription, formatImagePlaceholder, buildVisionUserContent, uniquifyMediaElementIds, formatTeacherPersonaForPrompt } from '@/lib/generation/generation-pipeline'; +import type { AgentInfo } from '@/lib/generation/generation-pipeline'; +import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; +import { nanoid } from 'nanoid'; +import type { UserRequirements, PdfImage, SceneOutline, ImageMapping } from '@/lib/types/generation'; +import { apiError } from '@/lib/server/api-response' +import { createLogger } from '@/lib/logger' +const log = createLogger('Outlines Stream') + + +export const maxDuration = 300; + +/** + * Incremental JSON array parser. + * Extracts complete top-level objects from a partially-streamed JSON array. + * Returns newly found objects (skipping `alreadyParsed` count). + */ +function extractNewOutlines(buffer: string, alreadyParsed: number): SceneOutline[] { + const results: SceneOutline[] = []; + + // Find the start of the JSON array (skip any markdown fencing) + const stripped = buffer.replace(/^[\s\S]*?(?=\[)/, ''); + const arrayStart = stripped.indexOf('['); + if (arrayStart === -1) return results; + + let depth = 0; + let objectStart = -1; + let inString = false; + let escaped = false; + let objectCount = 0; + + for (let i = arrayStart + 1; i < stripped.length; i++) { + const char = stripped[i]; + + if (escaped) { escaped = false; continue; } + if (char === '\\' && inString) { escaped = true; continue; } + if (char === '"') { inString = !inString; continue; } + if (inString) continue; + + if (char === '{') { + if (depth === 0) objectStart = i; + depth++; + } else if (char === '}') { + depth--; + if (depth === 0 && objectStart >= 0) { + objectCount++; + if (objectCount > alreadyParsed) { + try { + const obj = JSON.parse(stripped.substring(objectStart, i + 1)); + results.push(obj); + } catch { + // Incomplete or invalid JSON — skip + } + } + objectStart = -1; + } + } + } + + return results; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + // Get API configuration from request headers + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + if (!body.requirements) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Requirements are required'); + } + + const { requirements, pdfText, pdfImages, imageMapping, researchContext, agents } = body as { + requirements: UserRequirements; + pdfText?: string; + pdfImages?: PdfImage[]; + imageMapping?: ImageMapping; + researchContext?: string; + agents?: AgentInfo[]; + }; + + // Detect vision capability + const hasVision = !!modelInfo?.capabilities?.vision; + + // Build prompt (same logic as generateSceneOutlinesFromRequirements) + let availableImagesText = requirements.language === 'zh-CN' ? '无可用图片' : 'No images available'; + let visionImages: Array<{ id: string; src: string }> | undefined; + + if (pdfImages && pdfImages.length > 0) { + if (hasVision && imageMapping) { + // Vision mode: split into vision images (first N) and text-only (rest) + const allWithSrc = pdfImages.filter(img => imageMapping[img.id]); + const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES); + const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES); + const noSrcImages = pdfImages.filter(img => !imageMapping[img.id]); + + const visionDescriptions = visionSlice.map(img => formatImagePlaceholder(img, requirements.language)); + const textDescriptions = [...textOnlySlice, ...noSrcImages].map(img => formatImageDescription(img, requirements.language)); + availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n'); + + visionImages = visionSlice.map(img => ({ id: img.id, src: imageMapping[img.id], width: img.width, height: img.height })); + } else { + // Text-only mode: full descriptions + availableImagesText = pdfImages.map(img => + formatImageDescription(img, requirements.language) + ).join('\n'); + } + } + + // Build media generation policy based on enabled flags + const imageGenerationEnabled = req.headers.get('x-image-generation-enabled') === 'true'; + const videoGenerationEnabled = req.headers.get('x-video-generation-enabled') === 'true'; + let mediaGenerationPolicy = ''; + if (!imageGenerationEnabled && !videoGenerationEnabled) { + mediaGenerationPolicy = '**IMPORTANT: Do NOT include any mediaGenerations in the outlines. Both image and video generation are disabled.**'; + } else if (!imageGenerationEnabled) { + mediaGenerationPolicy = '**IMPORTANT: Do NOT include any image mediaGenerations (type: "image") in the outlines. Image generation is disabled. Video generation is allowed.**'; + } else if (!videoGenerationEnabled) { + mediaGenerationPolicy = '**IMPORTANT: Do NOT include any video mediaGenerations (type: "video") in the outlines. Video generation is disabled. Image generation is allowed.**'; + } + + // Build teacher context from agents (if available) + const teacherContext = formatTeacherPersonaForPrompt(agents); + + const prompts = buildPrompt(PROMPT_IDS.REQUIREMENTS_TO_OUTLINES, { + requirement: requirements.requirement, + language: requirements.language, + pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : (requirements.language === 'zh-CN' ? '无' : 'None'), + availableImages: availableImagesText, + researchContext: researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'), + mediaGenerationPolicy, + teacherContext, + }); + + if (!prompts) { + return apiError('INTERNAL_ERROR', 500, 'Prompt template not found'); + } + + log.info(`Generating outlines: "${requirements.requirement.substring(0, 50)}" [model=${modelString}]`); + + // Create SSE stream with heartbeat to prevent connection timeout + const encoder = new TextEncoder(); + const HEARTBEAT_INTERVAL_MS = 15_000; + const stream = new ReadableStream({ + async start(controller) { + // Heartbeat: periodically send SSE comments to keep the connection alive. + let heartbeatTimer: ReturnType | null = null; + const startHeartbeat = () => { + stopHeartbeat(); + heartbeatTimer = setInterval(() => { + try { + controller.enqueue(encoder.encode(`:heartbeat\n\n`)); + } catch { + stopHeartbeat(); + } + }, HEARTBEAT_INTERVAL_MS); + }; + const stopHeartbeat = () => { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + }; + + const MAX_STREAM_RETRIES = 2; + + try { + startHeartbeat(); + + const streamParams = (visionImages?.length) + ? { + model: languageModel, + system: prompts.system, + messages: [{ role: 'user' as const, content: buildVisionUserContent(prompts.user, visionImages) }], + maxOutputTokens: modelInfo?.outputWindow, + } + : { + model: languageModel, + system: prompts.system, + prompt: prompts.user, + maxOutputTokens: modelInfo?.outputWindow, + }; + + let parsedOutlines: SceneOutline[] = []; + let lastError: string | undefined; + + for (let attempt = 1; attempt <= MAX_STREAM_RETRIES + 1; attempt++) { + try { + const result = streamLLM(streamParams, 'scene-outlines-stream'); + + let fullText = ''; + parsedOutlines = []; + + for await (const chunk of result.textStream) { + fullText += chunk; + + // Try to extract new outlines from the accumulated text + const newOutlines = extractNewOutlines(fullText, parsedOutlines.length); + for (const outline of newOutlines) { + // Ensure ID and order + const enriched = { + ...outline, + id: outline.id || nanoid(), + order: parsedOutlines.length + 1, + }; + parsedOutlines.push(enriched); + + const event = JSON.stringify({ + type: 'outline', + data: enriched, + index: parsedOutlines.length - 1, + }); + controller.enqueue(encoder.encode(`data: ${event}\n\n`)); + } + } + + // Validate: got outlines? + if (parsedOutlines.length > 0) break; + + // Empty result — retry if we have attempts left + lastError = fullText.trim() + ? 'LLM response could not be parsed into outlines' + : 'LLM returned empty response'; + + if (attempt <= MAX_STREAM_RETRIES) { + log.warn(`Empty outlines (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`); + // Notify client a retry is happening + const retryEvent = JSON.stringify({ type: 'retry', attempt, maxAttempts: MAX_STREAM_RETRIES + 1 }); + controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`)); + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + + if (attempt <= MAX_STREAM_RETRIES) { + log.warn(`Stream error (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`, error); + const retryEvent = JSON.stringify({ type: 'retry', attempt, maxAttempts: MAX_STREAM_RETRIES + 1 }); + controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`)); + continue; + } + } + } + + if (parsedOutlines.length > 0) { + // Replace sequential gen_img_N/gen_vid_N with globally unique IDs + const uniquifiedOutlines = uniquifyMediaElementIds(parsedOutlines); + // Send done event with all outlines + const doneEvent = JSON.stringify({ + type: 'done', + outlines: uniquifiedOutlines, + }); + controller.enqueue(encoder.encode(`data: ${doneEvent}\n\n`)); + } else { + // All retries exhausted, no outlines produced + log.error(`Outline generation failed after ${MAX_STREAM_RETRIES + 1} attempts: ${lastError}`); + const errorEvent = JSON.stringify({ + type: 'error', + error: lastError || 'Failed to generate outlines', + }); + controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`)); + } + } catch (error) { + const errorEvent = JSON.stringify({ + type: 'error', + error: error instanceof Error ? error.message : String(error), + }); + controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`)); + } finally { + stopHeartbeat(); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); + } catch (error) { + log.error('Streaming error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/scene-outlines/route.ts b/app/api/generate/scene-outlines/route.ts new file mode 100644 index 000000000..deb8d67bb --- /dev/null +++ b/app/api/generate/scene-outlines/route.ts @@ -0,0 +1,83 @@ +/** + * Scene Outlines Generation API + * + * Generates scene outlines directly from user requirements. + */ + +import { NextRequest } from 'next/server'; +import { generateText } from 'ai'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { generateSceneOutlinesFromRequirements } from '@/lib/generation/generation-pipeline'; +import type { UserRequirements, PdfImage } from '@/lib/types/generation'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response' +const log = createLogger('Outlines') + + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + // Get API configuration from request headers + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + // Parse model string and resolve server-side fallback + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // AI call function for the pipeline + const aiCall = async (systemPrompt: string, userPrompt: string): Promise => { + const result = await generateText({ + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }); + return result.text; + }; + + // Validate request body + if (!body.requirements) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Requirements are required'); + } + + const { requirements, pdfText, pdfImages } = body as { + requirements: UserRequirements; + pdfText?: string; + pdfImages?: PdfImage[]; + }; + + const imageGenerationEnabled = req.headers.get('x-image-generation-enabled') === 'true'; + const videoGenerationEnabled = req.headers.get('x-video-generation-enabled') === 'true'; + + const result = await generateSceneOutlinesFromRequirements(requirements, pdfText, pdfImages, aiCall, undefined, { + imageGenerationEnabled, + videoGenerationEnabled, + }); + + if (!result.success) { + return apiError('GENERATION_FAILED', 500, result.error || 'Failed to generate scene outlines'); + } + + return apiSuccess({ outlines: result.data }); + } catch (error) { + log.error('Generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/scene/route.ts b/app/api/generate/scene/route.ts new file mode 100644 index 000000000..24d5c809a --- /dev/null +++ b/app/api/generate/scene/route.ts @@ -0,0 +1,191 @@ +/** + * Single-Scene Generation API (JSON) + * + * Generates one scene at a time from a given outline. + * No streaming, no TTS — just scene content generation. + * The client calls this once per scene and handles orchestration. + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { + buildSceneFromOutline, + buildVisionUserContent, + type SceneGenerationContext, + type AgentInfo, +} from '@/lib/generation/generation-pipeline'; +import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation'; +import type { SpeechAction } from '@/lib/types/action'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('Scene API'); + +export const maxDuration = 60; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + outline, + allOutlines, + pdfImages, + imageMapping, + stageInfo, + stageId, + agents, + previousSpeeches: incomingPreviousSpeeches, + userProfile, + } = body as { + outline: SceneOutline; + allOutlines: SceneOutline[]; + pdfImages?: PdfImage[]; + imageMapping?: ImageMapping; + stageInfo: { + name: string; + description?: string; + language?: string; + style?: string; + }; + stageId: string; + agents?: AgentInfo[]; + previousSpeeches?: string[]; + userProfile?: string; + }; + + // Validate required fields + if (!outline) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required'); + } + if (!allOutlines || allOutlines.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'allOutlines is required and must not be empty'); + } + if (!stageId) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required'); + } + + // Ensure outline has language from stageInfo (fallback for older outlines) + if (!outline.language) { + outline.language = (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN'; + } + + // ── Model resolution from request headers ── + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // Detect vision capability + const hasVision = !!modelInfo?.capabilities?.vision; + + // Vision-aware AI call function for the pipeline + const aiCall = async ( + systemPrompt: string, + userPrompt: string, + images?: Array<{ id: string; src: string }>, + ): Promise => { + if (images?.length && hasVision) { + // Multimodal: use messages format with image content parts + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + messages: [ + { role: 'user' as const, content: buildVisionUserContent(userPrompt, images) }, + ], + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene', + ); + return result.text; + } + // Text-only fallback + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene', + ); + return result.text; + }; + + // ── Build cross-scene context ── + const allTitles = allOutlines.map((o) => o.title); + const pageIndex = allOutlines.findIndex((o) => o.id === outline.id); + const ctx: SceneGenerationContext = { + pageIndex: (pageIndex >= 0 ? pageIndex : 0) + 1, + totalPages: allOutlines.length, + allTitles, + previousSpeeches: incomingPreviousSpeeches ?? [], + }; + + // ── Filter images assigned to this outline ── + let assignedImages: PdfImage[] | undefined; + if ( + pdfImages && + pdfImages.length > 0 && + outline.suggestedImageIds && + outline.suggestedImageIds.length > 0 + ) { + const suggestedIds = new Set(outline.suggestedImageIds); + assignedImages = pdfImages.filter((img) => suggestedIds.has(img.id)); + } + + // ── Generate the scene ── + log.info(`Generating scene: "${outline.title}" (${outline.type}) [model=${modelString}]`); + + const scene = await buildSceneFromOutline( + outline, + aiCall, + stageId, + assignedImages, + imageMapping, + outline.type === 'pbl' ? languageModel : undefined, + hasVision, + ctx, + agents, + undefined, // onPhaseChange — not needed for non-streaming + userProfile, + ); + + if (!scene) { + log.error(`Failed to generate scene: "${outline.title}"`); + + return apiError('GENERATION_FAILED', 500, `Failed to generate scene: ${outline.title}`); + } + + // ── Extract speeches for cross-scene coherence ── + const outputPreviousSpeeches = (scene.actions || []) + .filter((a): a is SpeechAction => a.type === 'speech') + .map((a) => a.text); + + log.info( + `Scene generated successfully: "${outline.title}" — ${scene.actions?.length ?? 0} actions`, + ); + + return apiSuccess({ scene, previousSpeeches: outputPreviousSpeeches }); + } catch (error) { + log.error('Scene generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/tts/route.ts b/app/api/generate/tts/route.ts new file mode 100644 index 000000000..88f942428 --- /dev/null +++ b/app/api/generate/tts/route.ts @@ -0,0 +1,70 @@ +/** + * Single TTS Generation API + * + * Generates TTS audio for a single text string and returns base64-encoded audio. + * Called by the client in parallel for each speech action after a scene is generated. + * + * POST /api/generate/tts + */ + +import { NextRequest } from 'next/server'; +import { generateTTS } from '@/lib/audio/tts-providers'; +import { resolveTTSApiKey, resolveTTSBaseUrl } from '@/lib/server/provider-config'; +import type { TTSProviderId } from '@/lib/audio/types'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('TTS API'); + +export const maxDuration = 30; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { text, audioId, ttsProviderId, ttsVoice, ttsSpeed, ttsApiKey, ttsBaseUrl } = body as { + text: string; + audioId: string; + ttsProviderId: TTSProviderId; + ttsVoice: string; + ttsSpeed?: number; + ttsApiKey?: string; + ttsBaseUrl?: string; + }; + + // Validate required fields + if (!text || !audioId || !ttsProviderId || !ttsVoice) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required fields: text, audioId, ttsProviderId, ttsVoice'); + } + + // Reject browser-native TTS — must be handled client-side + if (ttsProviderId === 'browser-native-tts') { + return apiError('INVALID_REQUEST', 400, 'browser-native-tts must be handled client-side'); + } + + // Resolve API key and base URL (server-side fallback) + const apiKey = resolveTTSApiKey(ttsProviderId, ttsApiKey || undefined); + const baseUrl = resolveTTSBaseUrl(ttsProviderId, ttsBaseUrl || undefined); + + // Build TTS config + const config = { + providerId: ttsProviderId, + voice: ttsVoice, + speed: ttsSpeed ?? 1.0, + apiKey, + baseUrl, + }; + + log.info(`Generating TTS: provider=${ttsProviderId}, voice=${ttsVoice}, audioId=${audioId}, textLen=${text.length}`); + + // Generate audio + const { audio, format } = await generateTTS(config, text); + + // Convert to base64 + const base64 = Buffer.from(audio).toString('base64'); + + return apiSuccess({ audioId, base64, format }); + } catch (error) { + log.error('TTS generation error:', error); + return apiError('GENERATION_FAILED', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/video/route.ts b/app/api/generate/video/route.ts new file mode 100644 index 000000000..781e12220 --- /dev/null +++ b/app/api/generate/video/route.ts @@ -0,0 +1,77 @@ +/** + * Video Generation API + * + * Generates a video from a text prompt using the specified provider. + * Uses async task pattern (submit → poll) so maxDuration is set to 5 minutes. + * + * POST /api/generate/video + * + * Headers: + * x-video-provider: VideoProviderId (default: 'seedance') + * x-video-model: string (optional model override) + * x-api-key: string (optional, server fallback) + * x-base-url: string (optional, server fallback) + * + * Body: { prompt, duration?, aspectRatio?, resolution? } + * Response: { success: boolean, result?: VideoGenerationResult, error?: string } + */ + +import { NextRequest } from 'next/server'; +import { generateVideo, normalizeVideoOptions } from '@/lib/media/video-providers'; +import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-config'; +import type { VideoProviderId, VideoGenerationOptions } from '@/lib/media/types'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('VideoGeneration API'); + +export const maxDuration = 300; + +export async function POST(request: NextRequest) { + try { + const body = await request.json() as VideoGenerationOptions; + + if (!body.prompt) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt'); + } + + const providerId = (request.headers.get('x-video-provider') || 'seedance') as VideoProviderId; + const clientApiKey = request.headers.get('x-api-key') || undefined; + const clientBaseUrl = request.headers.get('x-base-url') || undefined; + const clientModel = request.headers.get('x-video-model') || undefined; + + const apiKey = resolveVideoApiKey(providerId, clientApiKey); + if (!apiKey) { + return apiError('MISSING_API_KEY', 401, `No API key configured for video provider: ${providerId}`); + } + + const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl); + + // Normalize options against provider capabilities + const options = normalizeVideoOptions(providerId, body); + + log.info( + `Generating video: provider=${providerId}, model=${clientModel || 'default'}, ` + + `prompt="${body.prompt.slice(0, 80)}...", duration=${options.duration ?? 'auto'}, ` + + `aspect=${options.aspectRatio ?? 'auto'}, resolution=${options.resolution ?? 'auto'}`, + ); + + const result = await generateVideo( + { providerId, apiKey, baseUrl, model: clientModel }, + options, + ); + + log.info(`Video generated: url=${result.url ? 'yes' : 'no'}, ${result.width}x${result.height}, ${result.duration}s`); + + return apiSuccess({ result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Detect content safety filter rejections (e.g. Seedance SensitiveContent errors) + if (message.includes('SensitiveContent') || message.includes('sensitive information')) { + log.warn(`Video blocked by content safety filter: ${message}`); + return apiError('CONTENT_SENSITIVE', 400, message); + } + log.error('Video generation error:', error); + return apiError('INTERNAL_ERROR', 500, message); + } +} diff --git a/app/api/parse-pdf/route.ts b/app/api/parse-pdf/route.ts new file mode 100644 index 000000000..50aeb7b8a --- /dev/null +++ b/app/api/parse-pdf/route.ts @@ -0,0 +1,69 @@ +import { NextRequest } from "next/server"; +import { parsePDF } from "@/lib/pdf/pdf-providers"; +import { resolvePDFApiKey, resolvePDFBaseUrl } from "@/lib/server/provider-config"; +import type { PDFProviderId } from "@/lib/pdf/types"; +import type { ParsedPdfContent } from "@/lib/types/pdf"; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Parse PDF') + + +export async function POST(req: NextRequest) { + try { + const contentType = req.headers.get('content-type') || ''; + if (!contentType.includes('multipart/form-data')) { + log.error('Invalid Content-Type for PDF upload:', contentType); + return apiError( + 'INVALID_REQUEST', + 400, + `Invalid Content-Type: expected multipart/form-data, got "${contentType}"`, + ); + } + + const formData = await req.formData(); + const pdfFile = formData.get("pdf") as File | null; + const providerId = formData.get("providerId") as PDFProviderId | null; + const apiKey = formData.get("apiKey") as string | null; + const baseUrl = formData.get("baseUrl") as string | null; + + if (!pdfFile) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'No PDF file provided'); + } + + // providerId is required from the client — no server-side store to fall back to + const effectiveProviderId = providerId || ('unpdf' as PDFProviderId); + + const config = { + providerId: effectiveProviderId, + apiKey: resolvePDFApiKey(effectiveProviderId, apiKey || undefined), + baseUrl: resolvePDFBaseUrl(effectiveProviderId, baseUrl || undefined), + }; + + // Convert PDF to buffer + const arrayBuffer = await pdfFile.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Parse PDF using the provider system + const result = await parsePDF(config, buffer); + + // Add file metadata + const resultWithMetadata: ParsedPdfContent = { + ...result, + metadata: { + pageCount: result.metadata?.pageCount || 0, // Ensure pageCount is always a number + ...result.metadata, + fileName: pdfFile.name, + fileSize: pdfFile.size, + }, + }; + + return apiSuccess({ data: resultWithMetadata }); + } catch (error) { + log.error('Error parsing PDF:', error); + return apiError( + 'PARSE_FAILED', + 500, + error instanceof Error ? error.message : 'Unknown error', + ); + } +} diff --git a/app/api/pbl/chat/route.ts b/app/api/pbl/chat/route.ts new file mode 100644 index 000000000..5f566378c --- /dev/null +++ b/app/api/pbl/chat/route.ts @@ -0,0 +1,87 @@ +/** + * PBL Runtime Chat API + * + * Handles @mention routing during PBL runtime. + * Students @question or @judge an agent, and this endpoint generates a response. + */ + +import { NextRequest } from 'next/server' +import { getModel, parseModelString } from '@/lib/ai/providers' +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config' +import { callLLM } from '@/lib/ai/llm' +import type { PBLAgent, PBLIssue } from '@/lib/pbl/types' +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response' +const log = createLogger('PBL Chat') + + +interface PBLChatRequest { + message: string + agent: PBLAgent + currentIssue: PBLIssue | null + recentMessages: { agent_name: string; message: string }[] + userRole: string + agentType?: 'question' | 'judge' +} + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as PBLChatRequest + const { message, agent, currentIssue, recentMessages, userRole, agentType } = body + + if (!message || !agent) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Message and agent are required'); + } + + // Get model config from headers (same pattern as scene-stream) + const modelString = req.headers.get('x-model') || 'gpt-4o-mini' + const clientApiKey = req.headers.get('x-api-key') || '' + const clientBaseUrl = req.headers.get('x-base-url') || undefined + const providerType = req.headers.get('x-provider-type') || undefined + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true' + + const { providerId, modelId } = parseModelString(modelString) + const apiKey = resolveApiKey(providerId, clientApiKey) + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl) + const proxy = resolveProxy(providerId); + const { model } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }) + + // Build context for the agent, differentiating question vs judge + let issueContext = '' + if (currentIssue) { + issueContext = `\n\n## Current Issue\nTitle: ${currentIssue.title}\nDescription: ${currentIssue.description}\nPerson in Charge: ${currentIssue.person_in_charge}` + if (currentIssue.generated_questions) { + if (agentType === 'judge') { + issueContext += `\n\nQuestions to Evaluate Against:\n${currentIssue.generated_questions}` + } else { + issueContext += `\n\nGenerated Questions:\n${currentIssue.generated_questions}` + } + } + } + + const recentContext = recentMessages.length > 0 + ? `\n\n## Recent Conversation\n${recentMessages.slice(-5).map(m => `${m.agent_name}: ${m.message}`).join('\n')}` + : '' + + const systemPrompt = `${agent.system_prompt}${issueContext}${recentContext}${userRole ? `\n\nThe student's role is: ${userRole}` : ''}` + + const result = await callLLM({ + model, + system: systemPrompt, + prompt: message, + }, 'pbl-chat') + + return apiSuccess({ message: result.text, agentName: agent.name }); + } catch (error) { + log.error('Error:', error) + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/proxy-media/route.ts b/app/api/proxy-media/route.ts new file mode 100644 index 000000000..88999e368 --- /dev/null +++ b/app/api/proxy-media/route.ts @@ -0,0 +1,60 @@ +/** + * Media Proxy API + * + * Server-side proxy for fetching remote media URLs (images/videos). + * Required because browser fetch() to remote CDN URLs fails with CORS errors. + * The media orchestrator uses this to download generated media as blobs + * for IndexedDB persistence. + * + * POST /api/proxy-media + * Body: { url: string } + * Response: Binary blob with appropriate Content-Type + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; +import { apiError } from '@/lib/server/api-response'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('ProxyMedia'); + +export const maxDuration = 60; + +export async function POST(request: NextRequest) { + try { + const { url } = await request.json(); + + if (!url || typeof url !== 'string') { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing or invalid url'); + } + + // Block local/private network URLs to prevent SSRF + const ssrfError = validateUrlForSSRF(url); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + + // Disable redirect following to prevent redirect-to-internal attacks + const response = await fetch(url, { redirect: 'manual' }); + if (response.status >= 300 && response.status < 400) { + return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed'); + } + if (!response.ok) { + return apiError('UPSTREAM_ERROR', 502, `Upstream returned ${response.status}`); + } + + const blob = await response.blob(); + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + + return new NextResponse(blob, { + headers: { + 'Content-Type': contentType, + 'Content-Length': String(blob.size), + 'Cache-Control': 'private, max-age=3600', + }, + }); + } catch (error) { + log.error('Proxy media error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts new file mode 100644 index 000000000..b31ea6df4 --- /dev/null +++ b/app/api/quiz-grade/route.ts @@ -0,0 +1,113 @@ +/** + * Quiz Grading API + * + * POST: Receives a text question + user answer, calls LLM for scoring and feedback. + * Used for short-answer (text) questions that cannot be graded locally. + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Quiz Grade') + + +interface GradeRequest { + question: string; + userAnswer: string; + points: number; + commentPrompt?: string; + language?: string; +} + +interface GradeResponse { + score: number; + comment: string; +} + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as GradeRequest; + const { question, userAnswer, points, commentPrompt, language } = body; + + if (!question || !userAnswer) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'question and userAnswer are required'); + } + + // Resolve model from request headers (same pattern as scene-stream) + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + const isZh = language === 'zh-CN'; + + const systemPrompt = isZh + ? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。 +必须以如下 JSON 格式回复(不要包含其他内容): +{"score": <0到${points}的整数>, "comment": "<一两句评语>"}` + : `You are a professional educational assessor. Grade the student's answer and provide brief feedback. +You must reply in the following JSON format only (no other content): +{"score": , "comment": ""}`; + + const userPrompt = isZh + ? `题目:${question} +满分:${points}分 +${commentPrompt ? `评分要点:${commentPrompt}\n` : ''}学生答案:${userAnswer}` + : `Question: ${question} +Full marks: ${points} points +${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${userAnswer}`; + + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + }, + 'quiz-grade', + ); + + // Parse the LLM response as JSON + const text = result.text.trim(); + let gradeResult: GradeResponse; + + try { + // Try to extract JSON from the response + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('No JSON found'); + const parsed = JSON.parse(jsonMatch[0]); + gradeResult = { + score: Math.max(0, Math.min(points, Math.round(Number(parsed.score)))), + comment: String(parsed.comment || ''), + }; + } catch { + // Fallback: give partial credit with a generic comment + gradeResult = { + score: Math.round(points * 0.5), + comment: isZh ? '已作答,请参考标准答案。' : 'Answer received. Please refer to the standard answer.', + }; + } + + return apiSuccess({ ...gradeResult }); + } catch (error) { + log.error('Error:', error); + return apiError('INTERNAL_ERROR', 500, 'Failed to grade answer'); + } +} diff --git a/app/api/server-providers/route.ts b/app/api/server-providers/route.ts new file mode 100644 index 000000000..00db6c899 --- /dev/null +++ b/app/api/server-providers/route.ts @@ -0,0 +1,30 @@ +import { + getServerProviders, + getServerTTSProviders, + getServerASRProviders, + getServerPDFProviders, + getServerImageProviders, + getServerVideoProviders, + getServerWebSearchProviders, +} from '@/lib/server/provider-config'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('ServerProviders'); + +export async function GET() { + try { + return apiSuccess({ + providers: getServerProviders(), + tts: getServerTTSProviders(), + asr: getServerASRProviders(), + pdf: getServerPDFProviders(), + image: getServerImageProviders(), + video: getServerVideoProviders(), + webSearch: getServerWebSearchProviders(), + }); + } catch (error) { + log.error('Error fetching server providers:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Unknown error'); + } +} diff --git a/app/api/transcription/route.ts b/app/api/transcription/route.ts new file mode 100644 index 000000000..d03efa4b5 --- /dev/null +++ b/app/api/transcription/route.ts @@ -0,0 +1,52 @@ +import { NextRequest } from 'next/server'; +import { transcribeAudio } from '@/lib/audio/asr-providers'; +import { resolveASRApiKey, resolveASRBaseUrl } from '@/lib/server/provider-config'; +import type { ASRProviderId } from '@/lib/audio/types'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Transcription') + + +export const maxDuration = 60; + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const audioFile = formData.get('audio') as File; + const providerId = formData.get('providerId') as ASRProviderId | null; + const language = formData.get('language') as string | null; + const apiKey = formData.get('apiKey') as string | null; + const baseUrl = formData.get('baseUrl') as string | null; + + if (!audioFile) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Audio file is required'); + } + + // providerId is required from the client — no server-side store to fall back to + const effectiveProviderId = providerId || ('openai-whisper' as ASRProviderId); + + const config = { + providerId: effectiveProviderId, + language: language || 'auto', + apiKey: resolveASRApiKey(effectiveProviderId, apiKey || undefined), + baseUrl: resolveASRBaseUrl(effectiveProviderId, baseUrl || undefined), + }; + + // Convert audio file to buffer + const arrayBuffer = await audioFile.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Transcribe using the provider system + const result = await transcribeAudio(config, buffer); + + return apiSuccess({ text: result.text }); + } catch (error) { + log.error('Transcription error:', error); + return apiError( + 'TRANSCRIPTION_FAILED', + 500, + 'Transcription failed', + error instanceof Error ? error.message : 'Unknown error', + ); + } +} diff --git a/app/api/verify-image-provider/route.ts b/app/api/verify-image-provider/route.ts new file mode 100644 index 000000000..75dc8fd51 --- /dev/null +++ b/app/api/verify-image-provider/route.ts @@ -0,0 +1,56 @@ +/** + * Verify Image Provider API + * + * Lightweight endpoint that validates provider credentials without generating images. + * + * POST /api/verify-image-provider + * + * Headers: + * x-image-provider: ImageProviderId + * x-image-model: string (optional) + * x-api-key: string (optional, server fallback) + * x-base-url: string (optional, server fallback) + * + * Response: { success: boolean, message: string } + */ + +import { NextRequest } from 'next/server'; +import { testImageConnectivity } from '@/lib/media/image-providers'; +import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-config'; +import type { ImageProviderId } from '@/lib/media/types'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('VerifyImageProvider'); + +export async function POST(request: NextRequest) { + try { + const providerId = (request.headers.get('x-image-provider') || 'seedream') as ImageProviderId; + const model = request.headers.get('x-image-model') || undefined; + const clientApiKey = request.headers.get('x-api-key') || undefined; + const clientBaseUrl = request.headers.get('x-base-url') || undefined; + + const apiKey = resolveImageApiKey(providerId, clientApiKey); + const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl); + + if (!apiKey) { + return apiError('MISSING_API_KEY', 400, 'No API key configured'); + } + + const result = await testImageConnectivity({ + providerId, + apiKey, + baseUrl, + model, + }); + + if (!result.success) { + return apiError('UPSTREAM_ERROR', 500, result.message); + } + + return apiSuccess({ message: result.message }); + } catch (err) { + log.error('Connectivity test error:', err); + return apiError('INTERNAL_ERROR', 500, `Connectivity test error: ${err}`); + } +} diff --git a/app/api/verify-model/route.ts b/app/api/verify-model/route.ts new file mode 100644 index 000000000..3f7d490e6 --- /dev/null +++ b/app/api/verify-model/route.ts @@ -0,0 +1,72 @@ +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { generateText } from 'ai'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Verify Model') + + +export async function POST(req: NextRequest) { + try { + const { apiKey, baseUrl, model, providerType, requiresApiKey } = await req.json(); + + if (!model) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Model name is required'); + } + + // Parse model string and resolve server-side fallback + const { providerId, modelId } = parseModelString(model); + const effectiveApiKey = resolveApiKey(providerId, apiKey); + const effectiveBaseUrl = resolveBaseUrl(providerId, baseUrl); + const proxy = resolveProxy(providerId); + let languageModel; + try { + const result = getModel({ + providerId, + modelId, + apiKey: effectiveApiKey || "", + baseUrl: effectiveBaseUrl || undefined, + proxy, + providerType: providerType as "openai" | "anthropic" | "google" | undefined, + requiresApiKey, + }); + languageModel = result.model; + } catch (error) { + return apiError('INVALID_REQUEST', 401, error instanceof Error ? error.message : String(error)); + } + + // Send a minimal test message + const { text } = await generateText({ + model: languageModel, + prompt: 'Say "OK" if you can hear me.', + }); + + return apiSuccess({ + message: 'Connection successful', + response: text, + }); + } catch (error) { + log.error('API test error:', error); + + let errorMessage = 'Connection failed'; + if (error instanceof Error) { + // Parse common error messages + if (error.message.includes('401') || error.message.includes('Unauthorized')) { + errorMessage = 'API key is invalid or expired'; + } else if (error.message.includes('404') || error.message.includes('not found')) { + errorMessage = 'Model not found or API endpoint error'; + } else if (error.message.includes('429')) { + errorMessage = 'API rate limit exceeded, please try again later'; + } else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) { + errorMessage = 'Cannot connect to API server, please check the Base URL'; + } else if (error.message.includes('timeout')) { + errorMessage = 'Connection timed out, please check your network'; + } else { + errorMessage = error.message; + } + } + + return apiError('INTERNAL_ERROR', 500, errorMessage); + } +} diff --git a/app/api/verify-video-provider/route.ts b/app/api/verify-video-provider/route.ts new file mode 100644 index 000000000..d11f9717c --- /dev/null +++ b/app/api/verify-video-provider/route.ts @@ -0,0 +1,56 @@ +/** + * Verify Video Provider API + * + * Lightweight endpoint that validates provider credentials without generating video. + * + * POST /api/verify-video-provider + * + * Headers: + * x-video-provider: VideoProviderId + * x-video-model: string (optional) + * x-api-key: string (optional, server fallback) + * x-base-url: string (optional, server fallback) + * + * Response: { success: boolean, message: string } + */ + +import { NextRequest } from 'next/server'; +import { testVideoConnectivity } from '@/lib/media/video-providers'; +import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-config'; +import type { VideoProviderId } from '@/lib/media/types'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('VerifyVideoProvider'); + +export async function POST(request: NextRequest) { + try { + const providerId = (request.headers.get('x-video-provider') || 'seedance') as VideoProviderId; + const model = request.headers.get('x-video-model') || undefined; + const clientApiKey = request.headers.get('x-api-key') || undefined; + const clientBaseUrl = request.headers.get('x-base-url') || undefined; + + const apiKey = resolveVideoApiKey(providerId, clientApiKey); + const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl); + + if (!apiKey) { + return apiError('MISSING_API_KEY', 400, 'No API key configured'); + } + + const result = await testVideoConnectivity({ + providerId, + apiKey, + baseUrl, + model, + }); + + if (!result.success) { + return apiError('UPSTREAM_ERROR', 500, result.message); + } + + return apiSuccess({ message: result.message }); + } catch (err) { + log.error('Connectivity test error:', err); + return apiError('INTERNAL_ERROR', 500, `Connectivity test error: ${err}`); + } +} diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts new file mode 100644 index 000000000..f2ff627f4 --- /dev/null +++ b/app/api/web-search/route.ts @@ -0,0 +1,51 @@ +/** + * Web Search API + * + * POST /api/web-search + * Simple JSON request/response using Tavily search. + */ + +import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; +import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('WebSearch'); + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { query, apiKey: clientApiKey } = body as { + query?: string; + apiKey?: string; + }; + + if (!query || !query.trim()) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required'); + } + + const apiKey = resolveWebSearchApiKey(clientApiKey); + if (!apiKey) { + return apiError( + 'MISSING_API_KEY', + 400, + 'Tavily API key is not configured. Set it in Settings → Web Search or set TAVILY_API_KEY env var.', + ); + } + + const result = await searchWithTavily({ query: query.trim(), apiKey }); + const context = formatSearchResultsAsContext(result); + + return apiSuccess({ + answer: result.answer, + sources: result.sources, + context, + query: result.query, + responseTime: result.responseTime, + }); + } catch (err) { + log.error('[WebSearch] Error:', err); + const message = err instanceof Error ? err.message : 'Web search failed'; + return apiError('INTERNAL_ERROR', 500, message); + } +} diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a373fd12bd9993855aba9612fd48d8b17f74d2a0 GIT binary patch literal 24341 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 + + const generationStartedRef = useRef(false); + + const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({ + onComplete: () => { + log.info('[Classroom] All scenes generated'); + }, + }); + + const loadClassroom = useCallback(async () => { + try { + await loadFromStorage(classroomId); + // Restore completed media generation tasks from IndexedDB + await useMediaGenerationStore.getState().restoreFromDB(classroomId); + // Restore generated agents for this stage + const { loadGeneratedAgentsForStage } = await import('@/lib/orchestration/registry/store'); + const agentIds = await loadGeneratedAgentsForStage(classroomId); + if (agentIds.length > 0) { + const { useSettingsStore } = await import('@/lib/store/settings'); + useSettingsStore.getState().setSelectedAgentIds(agentIds); + } + } catch (error) { + log.error('Failed to load classroom:', error); + setError(error instanceof Error ? error.message : 'Failed to load classroom'); + } finally { + setLoading(false); + } + }, [classroomId, loadFromStorage]); + + useEffect(() => { + // Reset loading state on course switch to unmount Stage during transition, + // preventing stale data from syncing back to the new course + setLoading(true); + setError(null); + generationStartedRef.current = false; + + // Clear previous classroom's media tasks to prevent cross-classroom contamination. + // Placeholder IDs (gen_img_1, gen_vid_1) are NOT globally unique across stages, + // so stale tasks from a previous classroom would shadow the new one's. + const mediaStore = useMediaGenerationStore.getState(); + mediaStore.revokeObjectUrls(); + useMediaGenerationStore.setState({ tasks: {} }); + + loadClassroom(); + + // Cancel ongoing generation when classroomId changes or component unmounts + return () => { + stop(); + }; + }, [classroomId, loadClassroom, stop]); + + // Auto-resume generation for pending outlines + useEffect(() => { + if (loading || error || generationStartedRef.current) return; + + const state = useStageStore.getState(); + const { outlines, scenes, stage } = state; + + // Check if there are pending outlines + const completedOrders = new Set(scenes.map(s => s.order)); + const hasPending = outlines.some(o => !completedOrders.has(o.order)); + + if (hasPending && stage) { + generationStartedRef.current = true; + + // Load generation params from sessionStorage (stored by generation-preview before navigating) + const genParamsStr = sessionStorage.getItem('generationParams'); + const params = genParamsStr ? JSON.parse(genParamsStr) : {}; + + // Reconstruct imageMapping from IndexedDB using pdfImages storageIds + const storageIds = (params.pdfImages || []) + .map((img: { storageId?: string }) => img.storageId) + .filter(Boolean); + + loadImageMapping(storageIds).then((imageMapping) => { + generateRemaining({ + pdfImages: params.pdfImages, + imageMapping, + stageInfo: { + name: stage.name || '', + description: stage.description, + language: stage.language, + style: stage.style, + }, + agents: params.agents, + userProfile: params.userProfile, + }); + }); + } else if (outlines.length > 0 && stage) { + // All scenes are generated, but some media may not have finished. + // Resume media generation for any tasks not yet in IndexedDB. + // generateMediaForOutlines skips already-completed tasks automatically. + generationStartedRef.current = true; + generateMediaForOutlines(outlines, stage.id).catch((err) => { + log.warn('[Classroom] Media generation resume error:', err); + }); + } + }, [loading, error, generateRemaining]); + + return ( + + +
+ {loading ? ( +
+
+

Loading classroom...

+
+
+ ) : error ? ( +
+
+

Error: {error}

+ +
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..615f7801f5ef8e4a3684be454243f0a1b417000f GIT binary patch literal 7482 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 H{Som$Z8#Zz literal 0 HcmV?d00001 diff --git a/app/generation-preview/layout.tsx b/app/generation-preview/layout.tsx new file mode 100644 index 000000000..6e4898b0a --- /dev/null +++ b/app/generation-preview/layout.tsx @@ -0,0 +1,10 @@ +// Force dynamic rendering since this page uses client-side hooks (useI18n) +export const dynamic = 'force-dynamic'; + +export default function GenerationPreviewLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx new file mode 100644 index 000000000..213c0645d --- /dev/null +++ b/app/generation-preview/page.tsx @@ -0,0 +1,1652 @@ +'use client'; + +import { useEffect, useState, Suspense, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion, AnimatePresence } from 'motion/react'; +import { + CheckCircle2, + FileText, + ScanLine, + Search, + Sparkles, + AlertCircle, + AlertTriangle, + ArrowLeft, + BarChart3, + Bot, + Puzzle, + Globe, + MousePointer2, + LayoutPanelLeft, + Clapperboard, + MessageSquare, + Focus, + Play, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { useStageStore } from '@/lib/store/stage'; +import { useSettingsStore } from '@/lib/store/settings'; +import { useAgentRegistry } from '@/lib/orchestration/registry/store'; +import { useI18n } from '@/lib/use-i18n'; +import { loadImageMapping, loadPdfBlob, cleanupOldImages, storeImages } from '@/lib/utils/image-storage'; +import { getCurrentModelConfig } from '@/lib/utils/model-config'; +import { db } from '@/lib/utils/database'; +import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; +import { nanoid } from 'nanoid'; +import type { Stage } from '@/lib/types/stage'; +import type { SceneOutline, UserRequirements, PdfImage, ImageMapping } from '@/lib/types/generation'; +import { AgentRevealModal } from '@/components/agent-reveal-modal'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('GenerationPreview'); + +// Session state stored in sessionStorage +interface GenerationSessionState { + sessionId: string; + requirements: UserRequirements; + pdfText: string; + pdfImages?: PdfImage[]; + imageStorageIds?: string[]; + imageMapping?: ImageMapping; + sceneOutlines?: SceneOutline[] | null; + currentStep: "generating" | "complete"; + // PDF deferred parsing fields + pdfStorageKey?: string; + pdfFileName?: string; + pdfProviderId?: string; + pdfProviderConfig?: { apiKey?: string; baseUrl?: string }; + // Web search context + researchContext?: string; + researchSources?: Array<{ title: string; url: string }>; +} + +type GenerationStep = { + id: string; + title: string; + description: string; + icon: React.ElementType; + type: 'analysis' | 'writing' | 'visual'; +}; + +const ALL_STEPS: GenerationStep[] = [ + { + id: 'pdf-analysis', + title: 'generation.analyzingPdf', + description: 'generation.analyzingPdfDesc', + icon: ScanLine, + type: 'analysis' + }, + { + id: 'web-search', + title: 'generation.webSearching', + description: 'generation.webSearchingDesc', + icon: Search, + type: 'analysis' + }, + { + id: 'agent-generation', + title: 'generation.agentGeneration', + description: 'generation.agentGenerationDesc', + icon: Bot, + type: 'writing' + }, + { + id: 'outline', + title: 'generation.generatingOutlines', + description: 'generation.generatingOutlinesDesc', + icon: FileText, + type: 'writing' + }, + { + id: 'slide-content', + title: 'generation.generatingSlideContent', + description: 'generation.generatingSlideContentDesc', + icon: LayoutPanelLeft, + type: 'visual' + }, + { + id: 'actions', + title: 'generation.generatingActions', + description: 'generation.generatingActionsDesc', + icon: Clapperboard, + type: 'visual' + }, +]; + +const getActiveSteps = (session: GenerationSessionState | null) => { + return ALL_STEPS.filter(step => { + if (step.id === 'pdf-analysis') return !!session?.pdfStorageKey; + if (step.id === 'web-search') return !!session?.requirements?.webSearch; + if (step.id === 'agent-generation') return useSettingsStore.getState().agentMode === 'auto'; + return true; + }); +}; + +function GenerationPreviewContent() { + const router = useRouter(); + const { t } = useI18n(); + const hasStartedRef = useRef(false); + const abortControllerRef = useRef(null); + + const [session, setSession] = useState(null); + const [sessionLoaded, setSessionLoaded] = useState(false); + const [error, setError] = useState(null); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isComplete] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + const [streamingOutlines, setStreamingOutlines] = useState(null); + const [truncationWarnings, setTruncationWarnings] = useState([]); + const [webSearchSources, setWebSearchSources] = useState>([]); + const [showAgentReveal, setShowAgentReveal] = useState(false); + const [generatedAgents, setGeneratedAgents] = useState>([]); + const agentRevealResolveRef = useRef<(() => void) | null>(null); + + // Compute active steps based on session state + const activeSteps = getActiveSteps(session); + + // Load session from sessionStorage + useEffect(() => { + cleanupOldImages(24).catch((e) => log.error(e)); + + const saved = sessionStorage.getItem("generationSession"); + if (saved) { + try { + const parsed = JSON.parse(saved) as GenerationSessionState; + setSession(parsed); + } catch (e) { + log.error("Failed to parse generation session:", e); + } + } + setSessionLoaded(true); + }, []); + + // Abort all in-flight requests on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + // Get API credentials from localStorage + const getApiHeaders = () => { + const modelConfig = getCurrentModelConfig(); + const settings = useSettingsStore.getState(); + const imageProviderConfig = settings.imageProvidersConfig?.[settings.imageProviderId]; + const videoProviderConfig = settings.videoProvidersConfig?.[settings.videoProviderId]; + return { + "Content-Type": "application/json", + "x-model": modelConfig.modelString, + "x-api-key": modelConfig.apiKey, + "x-base-url": modelConfig.baseUrl, + "x-provider-type": modelConfig.providerType || '', + "x-requires-api-key": modelConfig.requiresApiKey ? 'true' : 'false', + // Image generation provider + "x-image-provider": settings.imageProviderId || '', + "x-image-model": settings.imageModelId || '', + "x-image-api-key": imageProviderConfig?.apiKey || '', + "x-image-base-url": imageProviderConfig?.baseUrl || '', + // Video generation provider + "x-video-provider": settings.videoProviderId || '', + "x-video-model": settings.videoModelId || '', + "x-video-api-key": videoProviderConfig?.apiKey || '', + "x-video-base-url": videoProviderConfig?.baseUrl || '', + // Media generation toggles + "x-image-generation-enabled": String(settings.imageGenerationEnabled ?? false), + "x-video-generation-enabled": String(settings.videoGenerationEnabled ?? false), + }; + }; + + + // Auto-start generation when session is loaded + useEffect(() => { + if (session && !hasStartedRef.current) { + hasStartedRef.current = true; + startGeneration(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session]); + + // Main generation flow + const startGeneration = async () => { + if (!session) return; + + // Create AbortController for this generation run + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const signal = controller.signal; + + // Use a local mutable copy so we can update it after PDF parsing + let currentSession = session; + + setError(null); + setCurrentStepIndex(0); + + try { + // Compute active steps for this session (recomputed after session mutations) + let activeSteps = getActiveSteps(currentSession); + + // Determine if we need the PDF analysis step + const hasPdfToAnalyze = !!currentSession.pdfStorageKey && !currentSession.pdfText; + // If no PDF to analyze, skip to the next available step + if (!hasPdfToAnalyze) { + const firstNonPdfIdx = activeSteps.findIndex(s => s.id !== 'pdf-analysis'); + setCurrentStepIndex(Math.max(0, firstNonPdfIdx)); + } + + // Step 0: Parse PDF if needed + if (hasPdfToAnalyze) { + log.debug('=== Generation Preview: Parsing PDF ==='); + const pdfBlob = await loadPdfBlob(currentSession.pdfStorageKey!); + if (!pdfBlob) { + throw new Error(t('generation.pdfLoadFailed')); + } + + // Ensure pdfBlob is a valid Blob with content + if (!(pdfBlob instanceof Blob) || pdfBlob.size === 0) { + log.error('Invalid PDF blob:', { type: typeof pdfBlob, size: pdfBlob instanceof Blob ? pdfBlob.size : 'N/A' }); + throw new Error(t('generation.pdfLoadFailed')); + } + + // Wrap as a File to guarantee multipart/form-data with correct content-type + const pdfFile = new File([pdfBlob], currentSession.pdfFileName || 'document.pdf', { + type: 'application/pdf', + }); + + const parseFormData = new FormData(); + parseFormData.append('pdf', pdfFile); + + if (currentSession.pdfProviderId) { + parseFormData.append('providerId', currentSession.pdfProviderId); + } + if (currentSession.pdfProviderConfig?.apiKey?.trim()) { + parseFormData.append('apiKey', currentSession.pdfProviderConfig.apiKey); + } + if (currentSession.pdfProviderConfig?.baseUrl?.trim()) { + parseFormData.append('baseUrl', currentSession.pdfProviderConfig.baseUrl); + } + + const parseResponse = await fetch('/api/parse-pdf', { + method: 'POST', + body: parseFormData, + signal, + }); + + if (!parseResponse.ok) { + const errorData = await parseResponse.json(); + throw new Error(errorData.error || t('generation.pdfParseFailed')); + } + + const parseResult = await parseResponse.json(); + if (!parseResult.success || !parseResult.data) { + throw new Error(t('generation.pdfParseFailed')); + } + + let pdfText = parseResult.data.text as string; + + // Truncate if needed + if (pdfText.length > MAX_PDF_CONTENT_CHARS) { + pdfText = pdfText.substring(0, MAX_PDF_CONTENT_CHARS); + } + + // Create image metadata and store images + // Prefer metadata.pdfImages (both parsers now return this) + const rawPdfImages = parseResult.data.metadata?.pdfImages; + const images = rawPdfImages + ? rawPdfImages.map((img: { id: string; src?: string; pageNumber?: number; description?: string; width?: number; height?: number }) => ({ + id: img.id, + src: img.src || '', + pageNumber: img.pageNumber || 1, + description: img.description, + width: img.width, + height: img.height, + })) + : (parseResult.data.images as string[]).map((src: string, i: number) => ({ + id: `img_${i + 1}`, + src, + pageNumber: 1, + })); + + const imageStorageIds = await storeImages(images); + + const pdfImages: PdfImage[] = images.map((img: { id: string; src: string; pageNumber: number; description?: string; width?: number; height?: number }, i: number) => ({ + id: img.id, + src: '', + pageNumber: img.pageNumber, + description: img.description, + width: img.width, + height: img.height, + storageId: imageStorageIds[i], + })); + + // Update session with parsed PDF data + const updatedSession = { + ...currentSession, + pdfText, + pdfImages, + imageStorageIds, + pdfStorageKey: undefined, // Clear so we don't re-parse + }; + setSession(updatedSession); + sessionStorage.setItem('generationSession', JSON.stringify(updatedSession)); + + // Truncation warnings + const warnings: string[] = []; + if ((parseResult.data.text as string).length > MAX_PDF_CONTENT_CHARS) { + warnings.push(t('generation.textTruncated').replace('{n}', String(MAX_PDF_CONTENT_CHARS))); + } + if (images.length > MAX_VISION_IMAGES) { + warnings.push( + t('generation.imageTruncated') + .replace('{total}', String(images.length)) + .replace('{max}', String(MAX_VISION_IMAGES)) + ); + } + if (warnings.length > 0) { + setTruncationWarnings(warnings); + } + + // Reassign local reference for subsequent steps + currentSession = updatedSession; + activeSteps = getActiveSteps(currentSession); + } + + // Step: Web Search (if enabled) + const webSearchStepIdx = activeSteps.findIndex(s => s.id === 'web-search'); + if (currentSession.requirements.webSearch && webSearchStepIdx >= 0) { + setCurrentStepIndex(webSearchStepIdx); + setWebSearchSources([]); + + const wsSettings = useSettingsStore.getState(); + const wsApiKey = wsSettings.webSearchProvidersConfig?.[wsSettings.webSearchProviderId]?.apiKey; + const res = await fetch('/api/web-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: currentSession.requirements.requirement, + apiKey: wsApiKey || undefined, + }), + signal, + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: 'Web search failed' })); + throw new Error(data.error || t('generation.webSearchFailed')); + } + + const searchData = await res.json(); + const sources = (searchData.sources || []).map((s: { title: string; url: string }) => ({ title: s.title, url: s.url })); + setWebSearchSources(sources); + + const updatedSessionWithSearch = { + ...currentSession, + researchContext: searchData.context || '', + researchSources: sources, + }; + setSession(updatedSessionWithSearch); + sessionStorage.setItem('generationSession', JSON.stringify(updatedSessionWithSearch)); + currentSession = updatedSessionWithSearch; + activeSteps = getActiveSteps(currentSession); + } + + // Load imageMapping early (needed for both outline and scene generation) + let imageMapping: ImageMapping = {}; + if (currentSession.imageStorageIds && currentSession.imageStorageIds.length > 0) { + log.debug('Loading images from IndexedDB'); + imageMapping = await loadImageMapping(currentSession.imageStorageIds); + } else if (currentSession.imageMapping && Object.keys(currentSession.imageMapping).length > 0) { + log.debug('Using imageMapping from session (old format)'); + imageMapping = currentSession.imageMapping; + } + + // ── Agent generation (before outlines so persona can influence structure) ── + const settings = useSettingsStore.getState(); + let agents: Array<{ id: string; name: string; role: string; persona?: string }> = []; + + // Create stage client-side (needed for agent generation stageId) + const stageId = nanoid(10); + const stage: Stage = { + id: stageId, + name: extractTopicFromRequirement(currentSession.requirements.requirement), + description: '', + language: currentSession.requirements.language || 'zh-CN', + style: 'professional', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + if (settings.agentMode === 'auto') { + const agentStepIdx = activeSteps.findIndex(s => s.id === 'agent-generation'); + if (agentStepIdx >= 0) setCurrentStepIndex(agentStepIdx); + + try { + const allAvatars = [ + '/avatars/assist.png', '/avatars/assist-2.png', + '/avatars/clown.png', '/avatars/clown-2.png', + '/avatars/curious.png', '/avatars/curious-2.png', + '/avatars/note-taker.png', '/avatars/note-taker-2.png', + '/avatars/teacher.png', '/avatars/teacher-2.png', + '/avatars/thinker.png', '/avatars/thinker-2.png', + ]; + + // No outlines yet — agent generation uses only stage name + description + const agentResp = await fetch('/api/generate/agent-profiles', { + method: 'POST', + headers: getApiHeaders(), + body: JSON.stringify({ + stageInfo: { name: stage.name, description: stage.description }, + language: currentSession.requirements.language || 'zh-CN', + availableAvatars: allAvatars, + }), + signal, + }); + + if (!agentResp.ok) throw new Error('Agent generation failed'); + const agentData = await agentResp.json(); + if (!agentData.success) throw new Error(agentData.error || 'Agent generation failed'); + + // Save to IndexedDB and registry + const { saveGeneratedAgents } = await import('@/lib/orchestration/registry/store'); + const savedIds = await saveGeneratedAgents(stage.id, agentData.agents); + settings.setSelectedAgentIds(savedIds); + + // Show card-reveal modal, continue generation once all cards are revealed + setGeneratedAgents(agentData.agents); + setShowAgentReveal(true); + await new Promise(resolve => { + agentRevealResolveRef.current = resolve; + }); + + agents = savedIds + .map(id => useAgentRegistry.getState().getAgent(id)) + .filter(Boolean) + .map(a => ({ id: a!.id, name: a!.name, role: a!.role, persona: a!.persona })); + } catch (err: unknown) { + log.warn('[Generation] Agent generation failed, falling back to presets:', err); + const registry = useAgentRegistry.getState(); + agents = settings.selectedAgentIds + .map(id => registry.getAgent(id)) + .filter(Boolean) + .map(a => ({ id: a!.id, name: a!.name, role: a!.role, persona: a!.persona })); + } + } else { + // Preset mode — use selected agents (include persona) + const registry = useAgentRegistry.getState(); + agents = settings.selectedAgentIds + .map(id => registry.getAgent(id)) + .filter(Boolean) + .map(a => ({ id: a!.id, name: a!.name, role: a!.role, persona: a!.persona })); + } + + // ── Generate outlines (with agent personas for teacher context) ── + let outlines = currentSession.sceneOutlines; + + const outlineStepIdx = activeSteps.findIndex(s => s.id === 'outline'); + setCurrentStepIndex(outlineStepIdx >= 0 ? outlineStepIdx : 0); + if (!outlines || outlines.length === 0) { + log.debug("=== Generating outlines (SSE) ==="); + setStreamingOutlines([]); + + outlines = await new Promise((resolve, reject) => { + const collected: SceneOutline[] = []; + + fetch("/api/generate/scene-outlines-stream", { + method: "POST", + headers: getApiHeaders(), + body: JSON.stringify({ + requirements: currentSession.requirements, + pdfText: currentSession.pdfText, + pdfImages: currentSession.pdfImages, + imageMapping, + researchContext: currentSession.researchContext, + agents, + }), + signal, + }).then(res => { + if (!res.ok) { + return res.json().then(d => { + reject(new Error(d.error || t('generation.outlineGenerateFailed'))); + }); + } + + const reader = res.body?.getReader(); + if (!reader) { + reject(new Error(t('generation.streamNotReadable'))); + return; + } + + const decoder = new TextDecoder(); + let sseBuffer = ''; + + const pump = (): Promise => reader.read().then(({ done, value }) => { + if (value) { + sseBuffer += decoder.decode(value, { stream: !done }); + const lines = sseBuffer.split('\n'); + sseBuffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const evt = JSON.parse(line.slice(6)); + if (evt.type === 'outline') { + collected.push(evt.data); + setStreamingOutlines([...collected]); + } else if (evt.type === 'retry') { + collected.length = 0; + setStreamingOutlines([]); + setStatusMessage(t('generation.outlineRetrying')); + } else if (evt.type === 'done') { + resolve(evt.outlines || collected); + return; + } else if (evt.type === 'error') { + reject(new Error(evt.error)); + return; + } + } catch (e) { + log.error('Failed to parse outline SSE:', line, e); + } + } + } + if (done) { + if (collected.length > 0) { + resolve(collected); + } else { + reject(new Error(t('generation.outlineEmptyResponse'))); + } + return; + } + return pump(); + }); + + pump().catch(reject); + }).catch(reject); + }); + + const updatedSession = { ...currentSession, sceneOutlines: outlines }; + setSession(updatedSession); + sessionStorage.setItem( + "generationSession", + JSON.stringify(updatedSession) + ); + + // Outline generation succeeded — clear homepage draft cache + try { localStorage.removeItem('requirementDraft'); } catch { /* ignore */ } + + // Brief pause to let user see the final outline state + await new Promise(resolve => setTimeout(resolve, 800)); + } + + // Move to scene generation step + setStatusMessage(''); + if (!outlines || outlines.length === 0) { + throw new Error(t('generation.outlineEmptyResponse')); + } + + // Store stage and outlines + const store = useStageStore.getState(); + store.setStage(stage); + store.setOutlines(outlines); + + // Advance to slide-content step + const contentStepIdx = activeSteps.findIndex(s => s.id === 'slide-content'); + if (contentStepIdx >= 0) setCurrentStepIndex(contentStepIdx); + + // Build stageInfo and userProfile for API call + const stageInfo = { + name: stage.name, + description: stage.description, + language: stage.language, + style: stage.style, + }; + + const userProfile = (currentSession.requirements.userNickname || currentSession.requirements.userBio) + ? `Student: ${currentSession.requirements.userNickname || 'Unknown'}${currentSession.requirements.userBio ? ` — ${currentSession.requirements.userBio}` : ''}` + : undefined; + + // Generate ONLY the first scene + store.setGeneratingOutlines(outlines); + + const firstOutline = outlines[0]; + + // Step 2: Generate content (currentStepIndex is already 2) + const contentResp = await fetch('/api/generate/scene-content', { + method: 'POST', + headers: getApiHeaders(), + body: JSON.stringify({ + outline: firstOutline, + allOutlines: outlines, + pdfImages: currentSession.pdfImages, + imageMapping, + stageInfo, + stageId: stage.id, + agents, + }), + signal, + }); + + if (!contentResp.ok) { + const errorData = await contentResp.json().catch(() => ({ error: 'Request failed' })); + throw new Error(errorData.error || t('generation.sceneGenerateFailed')); + } + + const contentData = await contentResp.json(); + if (!contentData.success || !contentData.content) { + throw new Error(contentData.error || t('generation.sceneGenerateFailed')); + } + + // Generate actions (activate actions step indicator) + const actionsStepIdx = activeSteps.findIndex(s => s.id === 'actions'); + setCurrentStepIndex(actionsStepIdx >= 0 ? actionsStepIdx : currentStepIndex + 1); + + const actionsResp = await fetch('/api/generate/scene-actions', { + method: 'POST', + headers: getApiHeaders(), + body: JSON.stringify({ + outline: contentData.effectiveOutline || firstOutline, + allOutlines: outlines, + content: contentData.content, + stageId: stage.id, + agents, + previousSpeeches: [], + userProfile, + }), + signal, + }); + + if (!actionsResp.ok) { + const errorData = await actionsResp.json().catch(() => ({ error: 'Request failed' })); + throw new Error(errorData.error || t('generation.sceneGenerateFailed')); + } + + const data = await actionsResp.json(); + if (!data.success || !data.scene) { + throw new Error(data.error || t('generation.sceneGenerateFailed')); + } + + // Generate TTS for first scene (part of actions step — blocking) + if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') { + const ttsProviderConfig = settings.ttsProvidersConfig?.[settings.ttsProviderId]; + const speechActions = (data.scene.actions || []).filter( + (a: { type: string; text?: string }) => a.type === 'speech' && a.text + ); + + let ttsFailCount = 0; + for (const action of speechActions) { + const audioId = `tts_${action.id}`; + action.audioId = audioId; + try { + const resp = await fetch('/api/generate/tts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: action.text, + audioId, + ttsProviderId: settings.ttsProviderId, + ttsVoice: settings.ttsVoice, + ttsSpeed: settings.ttsSpeed, + ttsApiKey: ttsProviderConfig?.apiKey || undefined, + ttsBaseUrl: ttsProviderConfig?.baseUrl || undefined, + }), + signal, + }); + if (!resp.ok) { ttsFailCount++; continue; } + const ttsData = await resp.json(); + if (!ttsData.success) { ttsFailCount++; continue; } + const binary = atob(ttsData.base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([bytes], { type: `audio/${ttsData.format}` }); + await db.audioFiles.put({ id: audioId, blob, format: ttsData.format, createdAt: Date.now() }); + } catch (err) { + log.warn(`[TTS] Failed for ${audioId}:`, err); + ttsFailCount++; + } + } + + if (ttsFailCount > 0 && speechActions.length > 0) { + throw new Error(t('generation.speechFailed')); + } + } + + // Add scene to store and navigate + store.addScene(data.scene); + store.setCurrentSceneId(data.scene.id); + + // Set remaining outlines as skeleton placeholders + const remaining = outlines.filter((o) => o.order !== data.scene.order); + store.setGeneratingOutlines(remaining); + + // Store generation params for classroom to continue generation + sessionStorage.setItem('generationParams', JSON.stringify({ + pdfImages: currentSession.pdfImages, + agents, + userProfile, + })); + + sessionStorage.removeItem('generationSession'); + await store.saveToStorage(); + router.push(`/classroom/${stage.id}`); + } catch (err) { + // AbortError is expected when navigating away — don't show as error + if (err instanceof DOMException && err.name === 'AbortError') { + log.info('[GenerationPreview] Generation aborted'); + return; + } + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const extractTopicFromRequirement = (requirement: string): string => { + const trimmed = requirement.trim(); + if (trimmed.length <= 500) { + return trimmed; + } + return trimmed.substring(0, 500).trim() + "..."; + }; + + const goBackToHome = () => { + abortControllerRef.current?.abort(); + sessionStorage.removeItem("generationSession"); + router.push("/"); + }; + + // Still loading session from sessionStorage + if (!sessionLoaded) { + return ( +
+
+
+
+
+ ); + } + + // No session found + if (!session) { + return ( +
+ +
+ +

{t('generation.sessionNotFound')}

+

+ {t('generation.sessionNotFoundDesc')} +

+ +
+
+
+ ); + } + + const activeStep = activeSteps.length > 0 + ? activeSteps[Math.min(currentStepIndex, activeSteps.length - 1)] + : ALL_STEPS[0]; + + return ( +
+ {/* Background Decor */} +
+
+
+
+ + {/* Back button */} + + + + +
+ + + {/* Progress Dots */} +
+ {activeSteps.map((step, idx) => ( +
+ ))} +
+ + {/* Central Content */} +
+ {/* Icon / Visualizer Container */} +
+ + {error ? ( + + + + ) : isComplete ? ( + + + + ) : ( + + + + )} + +
+ + {/* Text Content */} +
+ + +

+ {error ? t('generation.generationFailed') : isComplete ? t('generation.generationComplete') : t(activeStep.title)} +

+

+ {error ? error : isComplete ? t('generation.classroomReady') : statusMessage || t(activeStep.description)} +

+
+
+ + {/* Truncation warning indicator */} + + {truncationWarnings.length > 0 && !error && !isComplete && ( + + + + + + + + +
+ {truncationWarnings.map((w, i) => ( +

{w}

+ ))} +
+
+
+
+ )} +
+
+
+ + + + {/* Footer Action */} +
+ + {error ? ( + + + + ) : !isComplete ? ( + + + {t('generation.aiWorking')} + {generatedAgents.length > 0 && !showAgentReveal && ( + + )} + + ) : null} + +
+
+ + {/* Agent Reveal Modal */} + setShowAgentReveal(false)} + onAllRevealed={() => { + agentRevealResolveRef.current?.(); + agentRevealResolveRef.current = null; + }} + /> +
+ ); +} + +// Step-specific visualizers +function StepVisualizer({ stepId, outlines, webSearchSources }: { + stepId: string; + outlines?: SceneOutline[] | null; + webSearchSources?: Array<{ title: string; url: string }>; +}) { + switch (stepId) { + case 'pdf-analysis': + return ; + case 'web-search': + return ; + case 'outline': + return ; + case 'agent-generation': + return ; + case 'slide-content': + return ; + case 'actions': + return ; + default: + return null; + } +} + +// PDF: Document with scanning laser line +function PdfScanVisualizer() { + return ( +
+ +
+
+ {[80, 60, 90, 45, 70].map((w, i) => ( + + ))} +
+ {/* Scanning laser */} + +
+ + + +
+ ); +} + +// Web Search: Miniature search engine results page with animated query + result rows +function WebSearchVisualizer({ sources }: { sources: Array<{ title: string; url: string }> }) { + const [activeResult, setActiveResult] = useState(0); + + // Cycle through result highlight when we have sources + useEffect(() => { + if (sources.length === 0) return; + const timer = setInterval(() => { + setActiveResult(prev => (prev + 1) % Math.min(sources.length, 4)); + }, 1400); + return () => clearInterval(timer); + }, [sources.length]); + + // Placeholder results for skeleton state + const skeletonResults = [ + { titleW: 70, urlW: 45, snippetW: [90, 60] }, + { titleW: 55, urlW: 50, snippetW: [80, 75] }, + { titleW: 65, urlW: 40, snippetW: [85, 50] }, + { titleW: 50, urlW: 55, snippetW: [70, 65] }, + ]; + + const ROW_H = 38; + + return ( +
+ {/* Background glow */} + + + {/* Search results card */} +
+ {/* Search bar header */} +
+ +
+ +
+
+ + {/* Results list */} +
+ {/* Sliding highlight */} + {sources.length > 0 && ( + + )} + + {sources.length === 0 ? ( + // Skeleton: pulsing result placeholders + skeletonResults.map((item, i) => ( + +
+
+
+ {item.snippetW.map((w, j) => ( +
+ ))} +
+ + )) + ) : ( + // Live results + sources.slice(0, 4).map((source, i) => { + const isActive = i === activeResult; + return ( + +
+ {source.title} +
+
+ {source.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 32)} +
+
+
+
+
+ + ); + }) + )} +
+ + {/* Scanning beam */} + +
+ + {/* Source count badge */} + {sources.length > 0 && ( + + + {sources.length} + + )} +
+ ); +} + +// Outline: Streams real outline data as it arrives from SSE +function StreamingOutlineVisualizer({ outlines }: { outlines: SceneOutline[] }) { + // Build display lines from outlines + const allLines: string[] = []; + outlines.forEach((outline, i) => { + allLines.push(`${i + 1}. ${outline.title}`); + outline.keyPoints?.slice(0, 2).forEach(kp => { + const text = kp.length > 18 ? kp.substring(0, 18) + '...' : kp; + allLines.push(` • ${text}`); + }); + }); + + return ( +
+
+
+
+ {allLines.length === 0 ? ( + // Waiting for first outline — show placeholder skeleton +
+ {[60, 80, 50, 70].map((w, i) => ( + + ))} +
+ ) : ( + allLines.map((line, i) => ( + + {line} + + )) + )} +
+ +
+ ); +} + +// Content: Cycles through distinct representations of Slides, Quiz, PBL, Interactive +function AgentGenerationVisualizer() { + return ( +
+
+ {[0, 1, 2].map(i => ( + +
?
+
+ ))} +
+
+ ); +} + +function ContentVisualizer() { + const [index, setIndex] = useState(0); + + // 0: Slide (Blue) + // 1: Quiz (Purple) + // 2: PBL (Amber) + // 3: Interactive (Emerald) + const totalTypes = 4; + + useEffect(() => { + const timer = setInterval(() => { + setIndex((prev) => (prev + 1) % totalTypes); + }, 3200); + return () => clearInterval(timer); + }, []); + + const variants = { + enter: { x: 50, opacity: 0, scale: 0.9, rotateY: -15 }, + center: { x: 0, opacity: 1, scale: 1, rotateY: 0 }, + exit: { x: -50, opacity: 0, scale: 0.9, rotateY: 15 }, + }; + + const getTheme = (idx: number) => { + switch (idx) { + case 0: return { + color: 'blue', + label: 'SLIDE', + badge: 'bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:border-blue-800' + }; + case 1: return { + color: 'purple', + label: 'QUIZ', + badge: 'bg-purple-100 text-purple-600 border-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:border-purple-800' + }; + case 2: return { + color: 'amber', + label: 'PBL', + badge: 'bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:border-amber-800' + }; + case 3: return { + color: 'emerald', + label: 'WEB', + badge: 'bg-emerald-100 text-emerald-600 border-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300 dark:border-emerald-800' + }; + default: return { color: 'blue', label: '', badge: '' }; + } + }; + + const theme = getTheme(index); + + return ( +
+ {/* Background glow based on current theme */} + + + {/* Subtle orbiting rings (pushed back, slower) */} + {[0, 1].map((i) => ( + + ))} + + {/* Main Content Container */} +
+ + + {/* Consistent Badge - Now outside content logic */} + + {theme.label} + + + {/* --- SLIDE TYPE --- */} + {index === 0 && ( +
+ +
+
+ {[0.8, 0.9, 0.6, 0.7].map((w, i) => ( + + ))} +
+ + + +
+
+ )} + + {/* --- QUIZ TYPE --- */} + {index === 1 && ( +
+ +
+ + +
+ {[0, 1, 2, 3].map((i) => ( + +
+
+ + ))} +
+
+ )} + + {/* --- PBL TYPE --- */} + {index === 2 && ( +
+
+ + +
+
+ {[0, 1, 2].map((col) => ( + +
+ {[0, 1].map((card) => ( +
+ ))} + + ))} +
+
+ )} + + {/* --- INTERACTIVE TYPE --- */} + {index === 3 && ( +
+ {/* Browser Chrome - Padded right to avoid badge */} +
+
+
+
+
+
+
+
+ +
+ + {[1,2,3].map(i =>
)} + +
+ + + + +
+
+
+ )} + + {/* Scanning beam (shared) */} + + + +
+
+ ); +} + +// Actions: Timeline of speech, spotlight, and interactions being orchestrated +function ActionsVisualizer() { + const [activeIdx, setActiveIdx] = useState(0); + + const actionItems = [ + { icon: MessageSquare, label: 'Speech', color: 'text-violet-500', activeBg: 'bg-violet-500/10', activeBorder: 'border-violet-200 dark:border-violet-800' }, + { icon: Focus, label: 'Spotlight', color: 'text-amber-500', activeBg: 'bg-amber-500/10', activeBorder: 'border-amber-200 dark:border-amber-800' }, + { icon: MessageSquare, label: 'Speech', color: 'text-violet-500', activeBg: 'bg-violet-500/10', activeBorder: 'border-violet-200 dark:border-violet-800' }, + { icon: Play, label: 'Interact', color: 'text-emerald-500', activeBg: 'bg-emerald-500/10', activeBorder: 'border-emerald-200 dark:border-emerald-800' }, + { icon: MessageSquare, label: 'Speech', color: 'text-violet-500', activeBg: 'bg-violet-500/10', activeBorder: 'border-violet-200 dark:border-violet-800' }, + ]; + + // Row height (py-1.5 = 6px×2 padding + icon ~16px) + gap 6px ≈ 34px per row + const ROW_H = 34; + + useEffect(() => { + const timer = setInterval(() => { + setActiveIdx(prev => (prev + 1) % actionItems.length); + }, 1600); + return () => clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ {/* Background pulse */} + + + {/* Timeline card */} +
+ {/* Header */} +
+ + +
+ + {/* Action items */} +
+ {/* Sliding highlight — absolute, animates via y transform, no layout impact */} + + + {actionItems.map((item, i) => { + const Icon = item.icon; + const isActive = i === activeIdx; + const isPast = i < activeIdx; + return ( + +
+ +
+
+ + {item.label} + +
+
+ {/* Pulsing dot — always rendered, opacity-controlled, no layout shift */} + + + ); + })} +
+ +
+
+ ); +} + +export default function GenerationPreviewPage() { + return ( + +
+
+
+
+
+ } + > + + + ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 000000000..8b9707cdf --- /dev/null +++ b/app/globals.css @@ -0,0 +1,209 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: #722ed1; + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.58 0.22 27); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: #8b47ea; + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} +/* ProseMirror Editor Styles */ +.prosemirror-editor { + cursor: text; +} + +.prosemirror-editor.format-painter { + cursor: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuMzUuMDEybC0uMDY2Ljk5OGE1LjI3MSA1LjI3MSAwIDAwLTEuMTg0LjA2IDMuOCAzLjggMCAwMC0uOTMzLjQ3MmMtLjQ0LjM1Ni0uNzgzLjgxMS0uOTk4IDEuMzI0bC4wMTgtLjAzNnY1LjEySDEuMDR2Ljk4aC0xLjA0bC0uMDAyIDQuMTVjLjE4Ny40MjYuNDYuODEuNzkxIDEuMTE3bC4xNzUuMTUyYy4yOTMuMjA4LjYxNS4zNzMuODkuNDcyLjQxLjA4Mi44My4xMTIgMS4yNDkuMDlsLjA1Ny45OTlhNi4wNjMgNi4wNjMgMCAwMS0xLjU4OC0uMTI5IDQuODM2IDQuODM2IDAgMDEtMS4yNS0uNjQ3IDQuNDYzIDQuNDYzIDAgMDEtLjgzOC0uODgzYy0uMjI0LjMzMi0uNS42NDItLjgyNC45MjdhNC4xMSA0LjExIDAgMDEtMS4zMDUuNjMzQTYuMTI2IDYuMTI2IDAgMDEwIDE1LjkwOWwuMDY4LS45OTdjLjQyNC4wMjYuODUtLjAwMSAxLjIxNy0uMDcuMzM2LS4wOTkuNjUxLS4yNTQuODk0LS40My40My0uMzguNzY1LS44NDcuOTgyLTEuMzY4bC0uMDA1LjAxNFY4LjkzSDIuMTE1di0uOThoMS4wNFYyLjg2MmEzLjc3IDMuNzcgMCAwMC0uNzc0LTEuMTY3bC0uMTY1LS4xNTZhMy4wNjQgMy4wNjQgMCAwMC0uODgtLjQ0OEE1LjA2MiA1LjA2MiAwIDAwLjA2NyAxLjAxTDAgLjAxMmE2LjE0IDYuMTQgMCAwMTEuNTkyLjExYy40NTMuMTM1Ljg3Ny4zNDUgMS4yOS42NS4zLjI2NS41NjUuNTY0Ljc4Ny44OS4yMzMtLjMzMS41Mi0uNjM0Ljg1My0uOTA0YTQuODM1IDQuODM1IDAgMDExLjMtLjY0OEE2LjE1NSA2LjE1NSAwIDAxNy4zNS4wMTJ6IiBmaWxsPSIjMEQwRDBEIi8+PHBhdGggZD0iTTE3LjM1IDE0LjVsNC41LTQuNS02LTZjLTIgMi0zIDItNS41IDIuNS40IDMuMiA0LjgzMyA2LjY2NyA3IDh6bTQuNTg4LTQuNDkzYS4zLjMgMCAwMC40MjQgMGwuNjgtLjY4YTEuNSAxLjUgMCAwMDAtMi4xMjJMMjEuNjkgNS44NTNsMi4wMjUtMS41ODNhMS42MjkgMS42MjkgMCAxMC0yLjI3OS0yLjI5NmwtMS42MDMgMi4wMjItMS4zNTctMS4zNTdhMS41IDEuNSAwIDAwLTIuMTIxIDBsLS42OC42OGEuMy4zIDAgMDAwIC40MjVsNi4yNjMgNi4yNjN6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTE1Ljg5MiAzLjk2MnMtMS4wMyAxLjIwMi0yLjQ5NCAxLjg5Yy0xLjAwNi40NzQtMi4xOC41ODYtMi43MzQuNjI3LS4yLjAxNS0uMzQ0LjIxLS4yNzYuMzk5LjI5Mi44MiAxLjExMiAyLjggMi42NTggNC4zNDYgMi4xMjYgMi4xMjcgMy42NTggMi45NjggNC4xNDIgMy4yMDMuMS4wNDguMjE0LjAzLjI5OC0uMDQyLjM4Ni0uMzI1IDEuNS0xLjI3NyAyLjIxLTEuOTg2Ljg5Mi0uODg5IDIuMTg3LTIuNDQ3IDIuMTg3LTIuNDQ3bS40NzkuMDU1YS4zLjMgMCAwMS0uNDI0IDBsLTYuMjY0LTYuMjYzYS4zLjMgMCAwMTAtLjQyNWwuNjgtLjY4YTEuNSAxLjUgMCAwMTIuMTIyIDBsMS4zNTcgMS4zNTcgMS42MDMtMi4wMjJhMS42MjkgMS42MjkgMCAxMTIuMjggMi4yOTZMMjEuNjkgNS44NTNsMS4zNTIgMS4zNTJhMS41IDEuNSAwIDAxMCAyLjEyMmwtLjY4LjY4eiIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+') 2 5, default !important; +} + +/* Setup-needed indicator animations */ +@keyframes setup-glow { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.4), 0 0 8px rgba(139, 92, 246, 0.15); + } + 50% { + box-shadow: 0 0 0 6px rgba(139, 92, 246, 0), 0 0 16px rgba(59, 130, 246, 0.2); + } +} + +@keyframes setup-ping { + 0% { transform: scale(1); opacity: 1; } + 75%, 100% { transform: scale(2.2); opacity: 0; } +} + +@keyframes setup-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } +} + +.animate-setup-glow { + animation: setup-glow 2.5s ease-in-out infinite; +} + +.animate-setup-ping { + animation: setup-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +.animate-setup-float { + animation: setup-float 3s ease-in-out infinite; +} + +@keyframes setup-border-breathe { + 0%, 100% { + border-color: rgba(139, 92, 246, 0.25) !important; + box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.05), inset 0 0 0 0 rgba(139, 92, 246, 0); + } + 50% { + border-color: rgba(139, 92, 246, 0.7) !important; + box-shadow: 0 0 16px 0 rgba(139, 92, 246, 0.1), inset 0 0 12px 0 rgba(139, 92, 246, 0.03); + } +} + +.animate-setup-border { + animation: setup-border-breathe 2.5s ease-in-out infinite; + border-style: solid !important; +} + +/* Animation for audio visualizer */ +@keyframes wave { + 0%, 100% { + height: 30%; + } + 50% { + height: 100%; + } +} + +/* Hide scrollbar by default, show on hover */ +@utility scrollbar-hide { + &::-webkit-scrollbar { display: none; } + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Shimmer sweep for skeleton loading */ +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 000000000..4e65fe978 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono, Inter } from "next/font/google"; +import "./globals.css"; +import "animate.css"; +import "katex/dist/katex.min.css"; +import { ThemeProvider } from "@/lib/use-theme"; +import { I18nProvider } from "@/lib/use-i18n"; +import { Toaster } from "@/components/ui/sonner"; +import { ServerProvidersInit } from "@/components/server-providers-init"; + +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "OpenMAIC", + description: + "The open-source AI interactive classroom. Upload a PDF to instantly generate an immersive, multi-agent learning experience.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + {children} + + + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 000000000..ff2579486 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,978 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion, AnimatePresence } from 'motion/react'; +import { + ArrowUp, + Check, + ChevronDown, + Clock, + Copy, + ImagePlus, + Pencil, + Trash2, + Settings, + Sun, + Moon, + Monitor, + BotOff, + ChevronUp, +} from 'lucide-react'; +import { useI18n } from '@/lib/use-i18n'; +import { createLogger } from '@/lib/logger'; +import { Button } from '@/components/ui/button'; +import { Textarea as UITextarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { SettingsDialog } from '@/components/settings'; +import { GenerationToolbar } from '@/components/generation-toolbar'; +import { AgentBar } from '@/components/agent-bar'; +import { useTheme } from '@/lib/use-theme'; +import { nanoid } from 'nanoid'; +import { storePdfBlob } from '@/lib/utils/image-storage'; +import type { UserRequirements } from '@/lib/types/generation'; +import { useSettingsStore } from '@/lib/store/settings'; +import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile'; +import { StageListItem, listStages, deleteStageData, getFirstSlideByStages } from '@/lib/utils/stage-storage'; +import { ThumbnailSlide } from '@/components/slide-renderer/components/ThumbnailSlide'; +import type { Slide } from '@/lib/types/slides'; +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/speech-button'; + +const log = createLogger('Home'); + +const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; +const LANGUAGE_STORAGE_KEY = 'generationLanguage'; +const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; + +interface FormState { + pdfFile: File | null; + requirement: string; + language: 'zh-CN' | 'en-US'; + webSearch: boolean; +} + +const initialFormState: FormState = { + pdfFile: null, + requirement: '', + language: 'zh-CN', + webSearch: false, +}; + +function HomePage() { + const { t, locale, setLocale } = useI18n(); + const { theme, setTheme } = useTheme(); + const router = useRouter(); + const [form, setForm] = useState(initialFormState); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsSection, setSettingsSection] = useState(undefined); + + // Draft cache for requirement text + const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } = useDraftCache({ key: 'requirementDraft' }); + + // Model setup state + const currentModelId = useSettingsStore((s) => s.modelId); + const [storeHydrated, setStoreHydrated] = useState(false); + const [recentOpen, setRecentOpen] = useState(true); + + // Hydrate client-only state after mount (avoids SSR mismatch) + /* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */ + useEffect(() => { + setStoreHydrated(true); + try { + const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY); + if (saved !== null) setRecentOpen(saved !== 'false'); + } catch { /* localStorage unavailable */ } + try { + const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY); + const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY); + const updates: Partial = {}; + if (savedWebSearch === 'true') updates.webSearch = true; + if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') { + updates.language = savedLanguage; + } else { + const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + updates.language = detected; + } + if (Object.keys(updates).length > 0) { + setForm((prev) => ({ ...prev, ...updates })); + } + } catch { /* localStorage unavailable */ } + }, []); + /* eslint-enable react-hooks/set-state-in-effect */ + + // Restore requirement draft from cache (derived state pattern — no effect needed) + const [prevCachedRequirement, setPrevCachedRequirement] = useState(cachedRequirement); + if (cachedRequirement !== prevCachedRequirement) { + setPrevCachedRequirement(cachedRequirement); + if (cachedRequirement) { + setForm((prev) => ({ ...prev, requirement: cachedRequirement })); + } + } + + const needsSetup = storeHydrated && !currentModelId; + const [languageOpen, setLanguageOpen] = useState(false); + const [themeOpen, setThemeOpen] = useState(false); + const [error, setError] = useState(null); + const [classrooms, setClassrooms] = useState([]); + const [thumbnails, setThumbnails] = useState>({}); + const toolbarRef = useRef(null); + const textareaRef = useRef(null); + + // Close dropdowns when clicking outside + useEffect(() => { + if (!languageOpen && !themeOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) { + setLanguageOpen(false); + setThemeOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [languageOpen, themeOpen]); + + const loadClassrooms = async () => { + try { + const list = await listStages(); + setClassrooms(list); + // Load first slide thumbnails + if (list.length > 0) { + const slides = await getFirstSlideByStages(list.map((c) => c.id)); + setThumbnails(slides); + } + } catch (err) { + log.error('Failed to load classrooms:', err); + } + }; + + 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: {} }); + + // eslint-disable-next-line react-hooks/set-state-in-effect -- Store hydration on mount + loadClassrooms(); + }, []); + + const handleDelete = async (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + try { + await deleteStageData(id); + toast.success('Classroom deleted'); + await loadClassrooms(); + } catch (err) { + log.error('Failed to delete classroom:', err); + toast.error('Failed to delete classroom'); + } + }; + + const updateForm = (field: K, value: FormState[K]) => { + setForm((prev) => ({ ...prev, [field]: value })); + try { + if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value)); + if (field === 'language') localStorage.setItem(LANGUAGE_STORAGE_KEY, String(value)); + if (field === 'requirement') updateRequirementCache(value as string); + } catch { /* ignore */ } + }; + + const showSetupToast = (icon: React.ReactNode, title: string, desc: string) => { + toast.custom((id) => ( +
{ toast.dismiss(id); setSettingsOpen(true); }} + > +
+ {icon} +
+
+

{title}

+

{desc}

+
+
+ +
+
+ ), { duration: 4000 }); + }; + + const handleGenerate = async () => { + // Validate setup before proceeding + if (!currentModelId) { + showSetupToast( + , + t('settings.modelNotConfigured'), + t('settings.setupNeeded'), + ); + setSettingsOpen(true); + return; + } + + if (!form.requirement.trim()) { + setError(t('upload.requirementRequired')); + return; + } + + setError(null); + + try { + const userProfile = useUserProfileStore.getState(); + const requirements: UserRequirements = { + requirement: form.requirement, + language: form.language, + userNickname: userProfile.nickname || undefined, + userBio: userProfile.bio || undefined, + webSearch: form.webSearch || undefined, + }; + + let pdfStorageKey: string | undefined; + let pdfFileName: string | undefined; + let pdfProviderId: string | undefined; + let pdfProviderConfig: { apiKey?: string; baseUrl?: string } | undefined; + + if (form.pdfFile) { + pdfStorageKey = await storePdfBlob(form.pdfFile); + pdfFileName = form.pdfFile.name; + + const settings = useSettingsStore.getState(); + pdfProviderId = settings.pdfProviderId; + const providerCfg = settings.pdfProvidersConfig?.[settings.pdfProviderId]; + if (providerCfg) { + pdfProviderConfig = { + apiKey: providerCfg.apiKey, + baseUrl: providerCfg.baseUrl, + }; + } + } + + const sessionState = { + sessionId: nanoid(), + requirements, + pdfText: '', + pdfImages: [], + imageStorageIds: [], + pdfStorageKey, + pdfFileName, + pdfProviderId, + pdfProviderConfig, + sceneOutlines: null, + currentStep: 'generating' as const, + }; + sessionStorage.setItem('generationSession', JSON.stringify(sessionState)); + + router.push('/generation-preview'); + } catch (err) { + log.error('Error preparing generation:', err); + setError(err instanceof Error ? err.message : t('upload.generateFailed')); + } + }; + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return t('classroom.today'); + if (diffDays === 1) return t('classroom.yesterday'); + if (diffDays < 7) return `${diffDays} ${t('classroom.daysAgo')}`; + return date.toLocaleDateString(); + }; + + const canGenerate = !!form.requirement.trim(); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + if (canGenerate) handleGenerate(); + } + }; + + return ( +
+ {/* ═══ Top-right pill (unchanged) ═══ */} +
+ {/* Language Selector */} +
+ + {languageOpen && ( +
+ + +
+ )} +
+ +
+ + {/* Theme Selector */} +
+ + {themeOpen && ( +
+ + + +
+ )} +
+ +
+ + {/* Settings Button */} +
+ + {needsSetup && ( + <> + + + + + + {t('settings.setupNeeded')} + + + )} +
+
+ { setSettingsOpen(open); if (!open) setSettingsSection(undefined); }} + initialSection={settingsSection} + /> + + {/* ═══ Background Decor ═══ */} +
+
+
+
+ + {/* ═══ Hero section: title + input (centered, wider) ═══ */} + + {/* ── Logo ── */} + + + {/* ── Slogan ── */} + + {t('home.slogan')} + + + {/* ── Unified input area ── */} + +
+ {/* ── Greeting + Profile + Agents ── */} +
+ +
+ +
+
+ + {/* Textarea */} +