diff --git a/src/lib/curriculum.ts b/src/lib/curriculum.ts index bb1e5a6..a05c691 100644 --- a/src/lib/curriculum.ts +++ b/src/lib/curriculum.ts @@ -112,6 +112,15 @@ export type InstructorCurriculumResponse = { mapOptions?: CurriculumMap[]; }; +export type CourseAiSetting = { + courseId: string; + enabled: boolean; + provider: 'OPENAI' | 'ANTHROPIC' | 'GEMINI' | 'QWEN'; + model: string; + hasApiKey: boolean; + updatedAt?: string | null; +}; + const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, ''); function uploadErrorMessage(status: number, fallback: string) { @@ -134,6 +143,33 @@ export async function getInstructorCurriculum(accessToken: string, groupId?: str return response.json() as Promise; } +export async function getCourseAiSetting(accessToken: string, courseId: string): Promise { + const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/${encodeURIComponent(courseId)}/ai-setting`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) throw new Error(`AI 에이전트 설정을 불러오지 못했습니다. (${response.status})`); + return response.json() as Promise; +} + +export async function updateCourseAiSetting( + accessToken: string, + courseId: string, + input: Pick & { apiKey?: string }, +): Promise { + const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/${encodeURIComponent(courseId)}/ai-setting`, { + method: 'PUT', + headers: { Authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!response.ok) { + const payload = await response.json().catch((): null => null); + throw new Error(payload && typeof payload === 'object' && 'message' in payload + ? String(payload.message) + : `AI 에이전트 설정을 저장하지 못했습니다. (${response.status})`); + } + return response.json() as Promise; +} + export async function updateInstructorCurriculum( accessToken: string, input: { diff --git a/src/pages/curriculum/CurriculumPage.tsx b/src/pages/curriculum/CurriculumPage.tsx index f26f52e..af4b8bb 100644 --- a/src/pages/curriculum/CurriculumPage.tsx +++ b/src/pages/curriculum/CurriculumPage.tsx @@ -1,15 +1,53 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react'; +import { Bot, CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, KeyRound, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react'; import { ConfirmDialog } from '../../components/ConfirmDialog'; import { WeekCoverEditor } from '../../components/WeekCoverEditor'; import { PageHeader } from '../../components/PageHeader'; import { useToast } from '../../components/ToastProvider'; -import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum'; +import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getCourseAiSetting, getInstructorCurriculum, updateCourseAiSetting, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CourseAiSetting, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum'; import { listInstructorGroups, type InstructorGroup } from '../../lib/groups'; import type { AuthSession } from '../../types/auth'; const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id'; +const AI_MODEL_DEFAULTS: Record = { + OPENAI: 'gpt-4.1-mini', + ANTHROPIC: 'claude-sonnet-4-5', + GEMINI: 'gemini-2.5-flash', + QWEN: 'qwen3.7-plus', +}; + +const AI_PROVIDER_LABELS: Record = { + OPENAI: 'OpenAI', + ANTHROPIC: 'Claude', + GEMINI: 'Gemini', + QWEN: 'Qwen', +}; + +const AI_MODEL_OPTIONS: Record> = { + OPENAI: [ + { value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' }, + { value: 'gpt-4.1', label: 'GPT-4.1' }, + { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, + ], + ANTHROPIC: [ + { value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, + { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' }, + { value: 'claude-opus-4-1', label: 'Claude Opus 4.1' }, + ], + GEMINI: [ + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, + ], + QWEN: [ + { value: 'qwen3.7-plus', label: 'Qwen 3.7 Plus' }, + { value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' }, + { value: 'qwen-vl-plus', label: 'Qwen VL Plus' }, + { value: 'qwen-vl-max', label: 'Qwen VL Max' }, + { value: 'qwen-plus', label: 'Qwen Plus (text)' }, + ], +}; type ModuleParameterDraft = { key: string; @@ -145,7 +183,14 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes const [referenceFile, setReferenceFile] = useState(null); const [exampleCodeTitle, setExampleCodeTitle] = useState(''); const [exampleCodeFile, setExampleCodeFile] = useState(null); - const [activeTab, setActiveTab] = useState<'apply' | 'edit' | 'modules' | 'maps' | 'examples'>('apply'); + const [activeTab, setActiveTab] = useState<'apply' | 'edit' | 'modules' | 'maps' | 'examples' | 'ai'>('apply'); + const [aiEnabled, setAiEnabled] = useState(false); + const [aiProvider, setAiProvider] = useState('OPENAI'); + const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI); + const [aiApiKey, setAiApiKey] = useState(''); + const [aiHasApiKey, setAiHasApiKey] = useState(false); + const [aiConfiguredProvider, setAiConfiguredProvider] = useState(null); + const [aiSettingLoading, setAiSettingLoading] = useState(false); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -156,7 +201,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes const selectedGroup = useMemo(() => groups.find((group) => group.id === selectedGroupId) ?? null, [groups, selectedGroupId]); const hasGroups = groups.length > 0; const selectedWeekCompleted = selectedWeek?.state === 'done'; - const primaryActionDisabled = saving || !selectedWeek || (activeTab === 'apply' && !selectedGroup); + const primaryActionDisabled = saving || aiSettingLoading || (activeTab !== 'ai' && !selectedWeek) || (activeTab === 'apply' && !selectedGroup); const selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? []; const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? []; const selectedWeekDeletable = selectedWeek?.canDelete === true; @@ -271,6 +316,54 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes }; }, [session.accessToken, selectedGroupId, selectedCourseId]); + useEffect(() => { + if (!selectedCourseId) return; + let cancelled = false; + setAiSettingLoading(true); + getCourseAiSetting(session.accessToken, selectedCourseId) + .then((setting) => { + if (cancelled) return; + setAiEnabled(setting.enabled); + setAiProvider(setting.provider); + setAiModel(setting.model); + setAiHasApiKey(setting.hasApiKey); + setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null); + setAiApiKey(''); + }) + .catch((reason: Error) => { + if (!cancelled) setError(reason.message); + }) + .finally(() => { + if (!cancelled) setAiSettingLoading(false); + }); + return () => { cancelled = true; }; + }, [selectedCourseId, session.accessToken]); + + const handleSaveAiSetting = async () => { + if (!selectedCourseId) return; + setSaving(true); + setError(null); + try { + const setting = await updateCourseAiSetting(session.accessToken, selectedCourseId, { + enabled: aiEnabled, + provider: aiProvider, + model: aiModel.trim(), + ...(aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}), + }); + setAiEnabled(setting.enabled); + setAiProvider(setting.provider); + setAiModel(setting.model); + setAiHasApiKey(setting.hasApiKey); + setAiConfiguredProvider(setting.hasApiKey ? setting.provider : null); + setAiApiKey(''); + showToast({ title: 'AI 에이전트 설정이 저장되었습니다.', message: setting.enabled ? `${setting.provider} · ${setting.model}` : '사용 안 함' }); + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'AI 에이전트 설정을 저장하지 못했습니다.'); + } finally { + setSaving(false); + } + }; + useEffect(() => { if (!selectedWeek) return; setDraftTagLabel(selectedWeek.tags[0] ?? ''); @@ -782,6 +875,13 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes > 예제 코드 + setAiEnabled(event.target.checked)} className="h-4 w-4 rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]" /> + 사용 + + + + +
+
+ AI 공급자 +
+ {(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => ( + + ))} +
+
+ + +
+ +
+

학생 적용 절차

+
    +
  1. 1. 학생이 에이전트 탭에서 요청을 입력합니다.
  2. +
  3. 2. AI가 현재 모듈 파일을 읽고 파일별 변경안을 만듭니다.
  4. +
  5. 3. 학생이 변경 내용을 확인하고 적용 버튼을 누릅니다.
  6. +
  7. 4. 변경 파일이 코드 에디터에 열리며 빌드와 실행은 학생이 직접 수행합니다.
  8. +
+
) : (