merge: add course AI provider settings
This commit is contained in:
@@ -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<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function getCourseAiSetting(accessToken: string, courseId: string): Promise<CourseAiSetting> {
|
||||
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<CourseAiSetting>;
|
||||
}
|
||||
|
||||
export async function updateCourseAiSetting(
|
||||
accessToken: string,
|
||||
courseId: string,
|
||||
input: Pick<CourseAiSetting, 'enabled' | 'provider' | 'model'> & { apiKey?: string },
|
||||
): Promise<CourseAiSetting> {
|
||||
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<CourseAiSetting>;
|
||||
}
|
||||
|
||||
export async function updateInstructorCurriculum(
|
||||
accessToken: string,
|
||||
input: {
|
||||
|
||||
@@ -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<CourseAiSetting['provider'], string> = {
|
||||
OPENAI: 'gpt-4.1-mini',
|
||||
ANTHROPIC: 'claude-sonnet-4-5',
|
||||
GEMINI: 'gemini-2.5-flash',
|
||||
QWEN: 'qwen3.7-plus',
|
||||
};
|
||||
|
||||
const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = {
|
||||
OPENAI: 'OpenAI',
|
||||
ANTHROPIC: 'Claude',
|
||||
GEMINI: 'Gemini',
|
||||
QWEN: 'Qwen',
|
||||
};
|
||||
|
||||
const AI_MODEL_OPTIONS: Record<CourseAiSetting['provider'], Array<{ value: string; label: string }>> = {
|
||||
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<File | null>(null);
|
||||
const [exampleCodeTitle, setExampleCodeTitle] = useState('');
|
||||
const [exampleCodeFile, setExampleCodeFile] = useState<File | null>(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<CourseAiSetting['provider']>('OPENAI');
|
||||
const [aiModel, setAiModel] = useState(AI_MODEL_DEFAULTS.OPENAI);
|
||||
const [aiApiKey, setAiApiKey] = useState('');
|
||||
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
||||
const [aiConfiguredProvider, setAiConfiguredProvider] = useState<CourseAiSetting['provider'] | null>(null);
|
||||
const [aiSettingLoading, setAiSettingLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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
|
||||
>
|
||||
예제 코드
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`h-8 rounded-md px-4 text-[13px] font-black transition ${activeTab === 'ai' ? 'bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.18)]' : 'text-[#475569] hover:bg-[#e2e8f0] hover:text-[#111827]'}`}
|
||||
onClick={() => setActiveTab('ai')}
|
||||
>
|
||||
AI 에이전트
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
className="h-10 min-w-[190px] rounded-md border border-[#cbd5e1] bg-white px-3 text-[13px] font-bold text-[#111827] shadow-sm outline-none transition hover:border-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe] disabled:border-[#d1d9e6] disabled:bg-[#f8fafc] disabled:text-[#94a3b8]"
|
||||
@@ -798,9 +898,9 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab !== 'examples' && !(activeTab === 'apply' && selectedWeekCompleted) ? (
|
||||
<button className="inline-flex h-10 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={() => void (activeTab === 'apply' ? handleApply() : activeTab === 'modules' || activeTab === 'maps' ? handleSaveModules() : handleSaveContent())} disabled={primaryActionDisabled}>
|
||||
<button className="inline-flex h-10 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={() => void (activeTab === 'ai' ? handleSaveAiSetting() : activeTab === 'apply' ? handleApply() : activeTab === 'modules' || activeTab === 'maps' ? handleSaveModules() : handleSaveContent())} disabled={primaryActionDisabled}>
|
||||
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : activeTab === 'apply' ? <CirclePlay size={15} aria-hidden="true" /> : <Save size={15} aria-hidden="true" />}
|
||||
{activeTab === 'apply' ? '진행하기' : activeTab === 'modules' ? '모듈 저장' : activeTab === 'maps' ? '맵 저장' : '내용 저장'}
|
||||
{activeTab === 'apply' ? '진행하기' : activeTab === 'modules' ? '모듈 저장' : activeTab === 'maps' ? '맵 저장' : activeTab === 'ai' ? 'AI 설정 저장' : '내용 저장'}
|
||||
</button>
|
||||
) : null}
|
||||
{activeTab === 'apply' ? (
|
||||
@@ -1184,6 +1284,88 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : activeTab === 'ai' ? (
|
||||
<div className="grid gap-5 p-5">
|
||||
<section>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot size={17} className="text-[#2563eb]" aria-hidden="true" />
|
||||
<h3 className="text-[15px] font-black text-[#111827]">강좌 AI 에이전트</h3>
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-[640px] text-[12px] font-semibold leading-6 text-[#64748b]">학생이 현재 모듈의 코드를 AI에게 분석시키고, 검토한 변경안을 코드 에디터에 적용할 수 있습니다.</p>
|
||||
</div>
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 text-[12px] font-black text-[#475569]">
|
||||
<input type="checkbox" checked={aiEnabled} onChange={(event) => setAiEnabled(event.target.checked)} className="h-4 w-4 rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]" />
|
||||
사용
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-[#e2e8f0] pt-5">
|
||||
<div>
|
||||
<span className="text-[12px] font-black text-[#475569]">AI 공급자</span>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4" role="radiogroup" aria-label="AI 공급자">
|
||||
{(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={aiProvider === provider}
|
||||
disabled={aiSettingLoading}
|
||||
onClick={() => {
|
||||
setAiProvider(provider);
|
||||
setAiModel(AI_MODEL_DEFAULTS[provider]);
|
||||
setAiApiKey('');
|
||||
}}
|
||||
className={`h-10 rounded-md border text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
aiProvider === provider
|
||||
? 'border-[#2563eb] bg-[#eff6ff] text-[#1d4ed8] shadow-[0_0_0_1px_rgba(37,99,235,0.12)]'
|
||||
: 'border-[#cbd5e1] bg-white text-[#475569] hover:border-[#94a3b8] hover:bg-[#f8fafc]'
|
||||
}`}
|
||||
>
|
||||
{AI_PROVIDER_LABELS[provider]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-4 block">
|
||||
<span className="text-[12px] font-black text-[#475569]">모델</span>
|
||||
<select
|
||||
value={aiModel}
|
||||
disabled={aiSettingLoading}
|
||||
onChange={(event) => setAiModel(event.target.value)}
|
||||
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
|
||||
>
|
||||
{AI_MODEL_OPTIONS[aiProvider].map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="mt-4 block">
|
||||
<span className="flex items-center gap-1.5 text-[12px] font-black text-[#475569]"><KeyRound size={13} aria-hidden="true" />API 키</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={aiApiKey}
|
||||
onChange={(event) => setAiApiKey(event.target.value)}
|
||||
placeholder={aiHasApiKey && aiConfiguredProvider === aiProvider ? '설정됨 · 변경할 때만 새 키 입력' : 'API 키 입력'}
|
||||
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">키는 서버에서 암호화되어 저장되며 학생 앱으로 전달되지 않습니다.</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-[#e2e8f0] pt-5">
|
||||
<h3 className="text-[13px] font-black text-[#111827]">학생 적용 절차</h3>
|
||||
<ol className="mt-3 grid gap-2 text-[12px] font-semibold leading-6 text-[#64748b]">
|
||||
<li>1. 학생이 에이전트 탭에서 요청을 입력합니다.</li>
|
||||
<li>2. AI가 현재 모듈 파일을 읽고 파일별 변경안을 만듭니다.</li>
|
||||
<li>3. 학생이 변경 내용을 확인하고 적용 버튼을 누릅니다.</li>
|
||||
<li>4. 변경 파일이 코드 에디터에 열리며 빌드와 실행은 학생이 직접 수행합니다.</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 p-5">
|
||||
|
||||
Reference in New Issue
Block a user